From 746a4496ba8e32f1ed809672cd41f1b1820233dd Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 17 Aug 2026 14:04:01 -0700 Subject: [PATCH 01/26] chore(deps): upgrade next to 16.3.1, its optimizer no longer deletes live code (#6777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): upgrade next to 16.3.1, its optimizer no longer deletes live code 16.3.0 was reverted in #6242 because its Turbopack optimizer modelled a bare `return ()` tail call inside an async function as returning the promise object, propagated that always-truthy fact through the caller's `await`, and deleted everything after the resulting `if`. That shipped two dead code paths to production: the whole `POST /api/credentials` create path, and the insert inside `upsertAsyncToolCall`. We reported it as vercel/next.js#96595. The fix — "[turbopack] Collapse nested promises in the analyzer" (vercel/next.js#96601) — folds `Promise>` to `Promise` in the analyzer, and was backported as #96675 and released in 16.3.1. Verified before taking the bump: - The minimal reproduction from the issue no longer reproduces on 16.3.1. All four routes keep their code; on 16.3.0 `/api/broken` lost everything after the `if`. - A production build of `apps/sim` on 16.3.1 still emits the markers whose disappearance was the original signal: `credential_connected` (43 files), `acquireOrganizationUserMutationLocks` (28), and the `upsertAsyncToolCall` insert-path warning (10). The `return await` hardening added to both sites in the revert stays as is, and so does the TypeScript toolchain configuration. 16.3.1 published 2026-08-13, so it is inside the 7-day `minimumReleaseAge` supply-chain window until 2026-08-20 and needs an exclusion to install. The alternative is sitting on 16.2.12, whose successor we already reverted once, so the entries go in dated and come out on the next touch of the file. The mermaid and js-yaml exclusions aged out on 2026-08-11 and 2026-08-07 and are dropped here per that same rule. * fix(deps): keep the musl and win32 SWC binaries in the lockfile The release-age exclusion only listed the four @next/swc platforms that package.json pins, but next declares all eight as its own optionalDependencies, so all eight are normally resolved into bun.lock. A gated optional dependency does not fail the install — bun drops it silently — so the first install stripped both musl variants and both win32 variants from the lockfile. That left the Alpine devcontainer and any Windows machine with no SWC binary to resolve. Adding the remaining four to the exclusion list restores all eight entries at 16.3.1. Worth knowing for the next time this happens: bun.lock is sticky here. Once an optional dependency has been dropped, re-running the install — even with --force, even with the age gate switched off entirely — does not bring it back, because the resolution is not reattempted. The lockfile has to be regenerated from a base that still contains the entries, which is why this restores bun.lock from staging before re-applying the bump. --- apps/docs/package.json | 2 +- apps/sim/next.config.ts | 7 ++- apps/sim/package.json | 2 +- bun.lock | 92 +++++++++----------------------------- bunfig.toml | 38 +++++++++++----- package.json | 12 ++--- packages/emcn/package.json | 2 +- 7 files changed, 60 insertions(+), 95 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index c97a7a6db54..56a6a98107a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -27,7 +27,7 @@ "fumadocs-mdx": "14.3.2", "fumadocs-openapi": "10.8.1", "fumadocs-ui": "16.8.5", - "next": "16.2.12", + "next": "16.3.1", "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index fd95ec30a4a..b5e7feebd7b 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -218,16 +218,15 @@ const nextConfig: NextConfig = { * it lives. Restoring across commits is separately undocumented-as-supported * (vercel/next.js#87283 reports stale HTML from a cache built elsewhere). * - * Keep the explicit pin even while we sit on 16.2.12: 16.3.0 flips this - * default to true for stable (vercel/next.js#94616), so dropping it would - * silently re-enable the slower cache the next time we take that bump. + * The explicit pin is load-bearing: 16.3.0 flipped this default to true for + * stable (vercel/next.js#94616), so dropping it re-enables the slower cache. */ turbopackFileSystemCacheForBuild: false, /** * TypeScript 7 ships no JavaScript compiler API until 7.1, so Next's default * checker cannot load it — this shells out to the project-local `tsc` instead. * Pinned because the failure mode is not slower type checking but none at all: - * without it 16.2.12 skips the stage silently in 138ms. + * 16.2.12 skipped the stage silently in 138ms. */ useTypeScriptCli: true, preloadEntriesOnStart: false, diff --git a/apps/sim/package.json b/apps/sim/package.json index 2f4f26592b8..b314feb3bce 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -195,7 +195,7 @@ "mssql": "12.7.0", "mysql2": "3.14.3", "neo4j-driver": "6.0.1", - "next": "16.2.12", + "next": "16.3.1", "next-mdx-remote": "^6.0.0", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", diff --git a/bun.lock b/bun.lock index 1d4fdd0c45c..ab7a7bc2940 100644 --- a/bun.lock +++ b/bun.lock @@ -23,10 +23,10 @@ "turbo": "2.9.14", }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.12", - "@next/swc-darwin-x64": "16.2.12", - "@next/swc-linux-arm64-gnu": "16.2.12", - "@next/swc-linux-x64-gnu": "16.2.12", + "@next/swc-darwin-arm64": "16.3.1", + "@next/swc-darwin-x64": "16.3.1", + "@next/swc-linux-arm64-gnu": "16.3.1", + "@next/swc-linux-x64-gnu": "16.3.1", }, }, "apps/desktop": { @@ -74,7 +74,7 @@ "fumadocs-mdx": "14.3.2", "fumadocs-openapi": "10.8.1", "fumadocs-ui": "16.8.5", - "next": "16.2.12", + "next": "16.3.1", "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -298,7 +298,7 @@ "mssql": "12.7.0", "mysql2": "3.14.3", "neo4j-driver": "6.0.1", - "next": "16.2.12", + "next": "16.3.1", "next-mdx-remote": "^6.0.0", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", @@ -490,7 +490,7 @@ "class-variance-authority": "^0.7.1", "framer-motion": "^12.5.0", "input-otp": "^1.4.2", - "next": "16.2.12", + "next": "16.3.1", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", @@ -724,12 +724,12 @@ "isolated-vm", ], "overrides": { - "@next/env": "16.2.12", + "@next/env": "16.3.1", "drizzle-orm": "^0.45.2", "e2b": "^2.36.1", "mermaid": "11.16.1", "minimatch": "^10.2.5", - "next": "16.2.12", + "next": "16.3.1", "postgres": "^3.4.5", "react": "19.2.4", "react-dom": "19.2.4", @@ -1388,23 +1388,23 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - "@next/env": ["@next/env@16.2.12", "", {}, "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg=="], + "@next/env": ["@next/env@16.3.1", "", {}, "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw=="], "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], @@ -1902,7 +1902,7 @@ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], @@ -3666,7 +3666,7 @@ "neo4j-driver-core": ["neo4j-driver-core@6.0.1", "", {}, "sha512-5I2KxICAvcHxnWdJyDqwu8PBAQvWVTlQH2ve3VQmtVdJScPqWhpXN1PiX5IIl+cRF3pFpz9GQF53B5n6s0QQUQ=="], - "next": ["next@16.2.12", "", { "dependencies": { "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.12", "@next/swc-darwin-x64": "16.2.12", "@next/swc-linux-arm64-gnu": "16.2.12", "@next/swc-linux-arm64-musl": "16.2.12", "@next/swc-linux-x64-gnu": "16.2.12", "@next/swc-linux-x64-musl": "16.2.12", "@next/swc-win32-arm64-msvc": "16.2.12", "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw=="], + "next": ["next@16.3.1", "", { "dependencies": { "@next/env": "16.3.1", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.1", "@next/swc-darwin-x64": "16.3.1", "@next/swc-linux-arm64-gnu": "16.3.1", "@next/swc-linux-arm64-musl": "16.3.1", "@next/swc-linux-x64-gnu": "16.3.1", "@next/swc-linux-x64-musl": "16.3.1", "@next/swc-win32-arm64-msvc": "16.3.1", "@next/swc-win32-x64-msvc": "16.3.1", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA=="], "next-mdx-remote": ["next-mdx-remote@6.0.0", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^4.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ=="], @@ -5228,10 +5228,6 @@ "neo4j-driver-bolt-connection/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - - "next/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], @@ -5672,54 +5668,6 @@ "mammoth/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "next/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - - "next/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - - "next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - - "next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "next/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], - - "next/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], - - "next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], - - "next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - - "next/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], - - "next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/bunfig.toml b/bunfig.toml index 1c62a63c2cf..90a586695c9 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,16 +5,34 @@ exact = true minimumReleaseAge = 604800 # @typescript/native-preview stays excluded permanently: it only publishes nightly # dev builds, so every version is structurally younger than any age gate. -# mermaid 11.16.1 (published 2026-08-04) clears five open Dependabot advisories that -# 11.15.0 carries: architecture-diagram and config-API prototype pollution, radar and -# XY-chart DoS, and CSS injection into siblings of the diagram. It is inside the 7-day -# window and cannot be installed without an exception; it ages out on 2026-08-11 — drop -# the entry then, and re-date this note on any further bump rather than deleting the entry -# early, because removing it while the pinned version is still inside the window blocks the -# bump outright. js-yaml 4.3.1 (published 2026-07-31) carries the CVE-2026-59870 !!omap -# quadratic-CPU fix, which was never backported to the 4.3.0 line; it ages out on 2026-08-07, -# so that entry can go on the next touch of this file. -minimumReleaseAgeExcludes = ["@typescript/native-preview", "mermaid", "js-yaml"] +# next 16.3.1 (published 2026-08-13) carries the Turbopack fix for vercel/next.js#96595, +# the dead-code elimination bug that deleted the POST /api/credentials create path in +# production and forced the 16.3.0 revert (#6242). Sitting on 16.2.12 to wait out the +# window means knowingly staying on a release whose successor we already reverted once, +# so the bump takes an exception instead. It ages out on 2026-08-20 — drop these entries +# then, and re-date this note on any further bump rather than deleting them early, because +# removing an entry while its pinned version is still inside the window blocks the bump +# outright. The mermaid and js-yaml entries aged out on 2026-08-11 and 2026-08-07 and are +# dropped here per that rule. +# All eight @next/swc platform packages are listed even though package.json pins only four: +# next declares all eight as its own optionalDependencies, so every one of them is resolved +# into bun.lock regardless of which host runs the install. A gated optional dependency does +# not fail the install — bun drops it silently — so omitting the musl and win32 entries here +# would quietly strip them from the lockfile, and the Alpine devcontainer and any Windows +# machine would then have no SWC binary to resolve. +minimumReleaseAgeExcludes = [ + "@typescript/native-preview", + "next", + "@next/env", + "@next/swc-darwin-arm64", + "@next/swc-darwin-x64", + "@next/swc-linux-arm64-gnu", + "@next/swc-linux-arm64-musl", + "@next/swc-linux-x64-gnu", + "@next/swc-linux-x64-musl", + "@next/swc-win32-arm64-msvc", + "@next/swc-win32-x64-msvc", +] [run] env = { NEXT_PUBLIC_APP_URL = "http://localhost:3000" } diff --git a/package.json b/package.json index 666b0cb93df..df8cd486af4 100644 --- a/package.json +++ b/package.json @@ -97,8 +97,8 @@ "overrides": { "react": "19.2.4", "react-dom": "19.2.4", - "next": "16.2.12", - "@next/env": "16.2.12", + "next": "16.3.1", + "@next/env": "16.3.1", "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", "minimatch": "^10.2.5", @@ -107,10 +107,10 @@ "e2b": "^2.36.1" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.12", - "@next/swc-darwin-x64": "16.2.12", - "@next/swc-linux-arm64-gnu": "16.2.12", - "@next/swc-linux-x64-gnu": "16.2.12" + "@next/swc-darwin-arm64": "16.3.1", + "@next/swc-darwin-x64": "16.3.1", + "@next/swc-linux-arm64-gnu": "16.3.1", + "@next/swc-linux-x64-gnu": "16.3.1" }, "devDependencies": { "@babel/parser": "7.29.2", diff --git a/packages/emcn/package.json b/packages/emcn/package.json index 55455c98a66..0387b5356b9 100644 --- a/packages/emcn/package.json +++ b/packages/emcn/package.json @@ -82,7 +82,7 @@ "class-variance-authority": "^0.7.1", "framer-motion": "^12.5.0", "input-otp": "^1.4.2", - "next": "16.2.12", + "next": "16.3.1", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", From d152fad78817de265cfecf9835dea2ccc6f197a2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 17 Aug 2026 14:22:38 -0700 Subject: [PATCH 02/26] chore(deps): bump js-yaml to 4.3.1 in sim-cli and drop the aged-out release-age waivers (#6784) js-yaml < 4.3.1 has quadratic CPU consumption in !!omap resolution (GHSA-5p4m-2wfm-xmqj / CVE-2026-59870). sim-cli builds with --packages=bundle, so its dev-scoped js-yaml is bundled into the published CLI. 4.3.1 is already what apps/sim pins, so this collapses sim-cli onto the hoisted copy. The minimumReleaseAge waivers for js-yaml and mermaid were temporary and have both aged past the 7-day window; leaving them behind would disable the supply-chain gate for those packages indefinitely. --- bun.lock | 4 +--- packages/sim-cli/package.json | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index ab7a7bc2940..9eb05193a71 100644 --- a/bun.lock +++ b/bun.lock @@ -601,7 +601,7 @@ "@xterm/headless": "6.0.0", "chalk": "5.6.2", "commander": "^11.1.0", - "js-yaml": "4.3.0", + "js-yaml": "4.3.1", "typescript": "^7.0.2", "vitest": "^4.1.0", }, @@ -5290,8 +5290,6 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "sim/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], - "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 432ee0f3fb3..856473edcee 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -53,7 +53,7 @@ "@xterm/headless": "6.0.0", "chalk": "5.6.2", "commander": "^11.1.0", - "js-yaml": "4.3.0", + "js-yaml": "4.3.1", "typescript": "^7.0.2", "vitest": "^4.1.0" } From ef225f99efc61f3b8333dcac35ccfe800592e6da Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 17 Aug 2026 15:23:29 -0700 Subject: [PATCH 03/26] fix(connectors): index Office documents and PDFs from SharePoint and OneDrive (#6785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(connectors): index Office documents and PDFs from SharePoint and OneDrive The SharePoint and OneDrive connectors filtered their listings against a 12-item plain-text extension whitelist, so a document library of .docx, .pdf or .xlsx files synced as "success, 0 documents" — no document, no failed row, and no log line, which is indistinguishable from a wrong folder path. Both whitelists had been unchanged since the connectors shipped, and Sim already parses all of these formats for a manually uploaded knowledge base document. Adds a shared `extractConnectorText` in connectors/utils that routes binary document formats through the same `parseBuffer` the upload path uses, so the OOXML zip-bomb guard and each parser's extraction limits apply. The previously-accepted text formats stay on their exact existing path: sending .csv through CsvParser would silently reformat every already-indexed connector document on its next re-index. Also logs a per-page count of files skipped for an unsupported extension. Unsupported files are counted rather than turned into failed document rows, so a library full of images does not fill the knowledge base with noise. * fix(connectors): never index a degraded document extraction `DocParser` and `PptxParser` never throw by design — on a legacy OLE `.doc`/`.ppt` or a deck with no extractable text they return a placeholder sentence or scraped ZIP internals so an interactive upload still shows the user something. Verified against real OOXML fixtures: an image-only `.pptx` yields 1.9KB of `[Content_Types].xml…` as "content", and a legacy `.ppt` yields "Unable to extract text from PowerPoint file." A connector sync would embed that into the vector index at scale, so it needs to tell a real extraction from a fabricated one. Adds a declared `degraded` flag to `FileParseMetadata`, set by exactly those two fallback paths, rather than having callers sniff `extractionMethod`. `DocParser`'s plaintext branch stays unflagged: a text file misnamed `.doc` is a genuine extraction. `extractConnectorText` now raises `ConnectorTextExtractionError` when a parsed format comes back degraded or blank, and SharePoint/OneDrive surface it as a skipped document via the existing `markSkipped` path — so the file appears in the knowledge base as a failed row telling the user to re-save it as DOCX/PPTX/XLSX, instead of being silently dropped or indexed as junk. The upload path is unaffected; it ignores the new flag. * fix(file-parsers): register the document variants the parsers already handle A document library holds whole format families, not just the headline extension of each. These all extract correctly with the libraries already installed — they were simply never registered, so every one of them was reported as an unsupported file type: docm dotx (WordprocessingML — mammoth reads word/document.xml regardless of the package content type) xlsm xlsb xltx ods (SheetJS reads every workbook container natively) pptm potx (PresentationML) odt odp (OpenDocument, via a new OpenDocumentParser) Verified against real fixtures built with jszip and SheetJS rather than assumed: officeparser identifies a Buffer by sniffing content with `file-type`, not by the name we pass, so the routing had to be measured. `ods` goes to the spreadsheet parser rather than OpenDocumentParser so its output keeps per-sheet structure. `rtf` is deliberately excluded: nothing bundled extracts it, and DocParser's plaintext branch would pass its control words through as if they were prose. Converts the registry from `require()` inside per-parser `try/catch` blocks that only logged to static imports. Every parser dependency is a regular, non-optional one, so a resolution failure should fail loudly — the old form produced a silently **empty** registry in which every format became `Unsupported file type`, with an empty "Supported types are:" list as the only clue. The heavy extraction libraries are still deferred inside the individual parsers, and connectors now import the registry lazily so the ~60 connectors that never touch a file do not pull SheetJS. Adds registry.test.ts, which exercises the real module: index.test.ts mocks `@/lib/file-parsers` itself, so it validated its own fake routing table and the real registry had no coverage at all. The new test gates every member of SupportedFileType on having a registered parser that supports buffer parsing. * fix(file-parsers): resolve the parser registry through a Map, not object keys The registry rewrite switched extension lookup from `Object.keys(parsers).includes(ext)` to a bracket read on an object literal, which also resolves inherited keys. `PARSERS['constructor']` therefore returned `Object` — truthy, with no parse methods — so a caller-supplied extension of `constructor` fell through to "does not support buffer parsing" instead of being rejected as an unsupported type, and `parseFile` would have raised a TypeError. It also disagreed with `isSupportedFileType`, which used `Object.hasOwn` and correctly returned false for the same input. A Map has no prototype chain to walk, so lookup and support check now agree by construction. `isSupportedFileType` also guards a non-string argument, which the try/catch it replaced used to absorb. --- apps/sim/connectors/onedrive/onedrive.ts | 92 +++--- .../connectors/sharepoint/sharepoint.test.ts | 119 ++++++- apps/sim/connectors/sharepoint/sharepoint.ts | 91 +++--- apps/sim/connectors/utils.test.ts | 190 ++++++++++- apps/sim/connectors/utils.ts | 156 +++++++++ apps/sim/lib/file-parsers/doc-parser.ts | 1 + apps/sim/lib/file-parsers/index.ts | 227 +++++--------- .../lib/file-parsers/opendocument-parser.ts | 73 +++++ .../lib/file-parsers/parser-formats.test.ts | 296 ++++++++++++++++++ apps/sim/lib/file-parsers/pptx-parser.ts | 1 + apps/sim/lib/file-parsers/registry.test.ts | 124 ++++++++ apps/sim/lib/file-parsers/types.ts | 20 ++ 12 files changed, 1163 insertions(+), 227 deletions(-) create mode 100644 apps/sim/lib/file-parsers/opendocument-parser.ts create mode 100644 apps/sim/lib/file-parsers/parser-formats.test.ts create mode 100644 apps/sim/lib/file-parsers/registry.test.ts diff --git a/apps/sim/connectors/onedrive/onedrive.ts b/apps/sim/connectors/onedrive/onedrive.ts index ef6a4f1366d..54d1caba7f2 100644 --- a/apps/sim/connectors/onedrive/onedrive.ts +++ b/apps/sim/connectors/onedrive/onedrive.ts @@ -6,7 +6,11 @@ import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/ import { CONNECTOR_MAX_FILE_BYTES, ConnectorFileTooLargeError, - htmlToPlainText, + ConnectorTextExtractionError, + connectorFileExtension, + extractConnectorText, + extractionFailedSkipReason, + isIndexableConnectorFile, isSkippedDocument, markSkipped, parseTagDate, @@ -18,23 +22,11 @@ import { const logger = createLogger('OneDriveConnector') -const SUPPORTED_EXTENSIONS = new Set([ - '.txt', - '.md', - '.html', - '.htm', - '.csv', - '.json', - '.xml', - '.yaml', - '.yml', - '.log', - '.rst', - '.tsv', -]) - const MAX_FILE_SIZE = CONNECTOR_MAX_FILE_BYTES +/** Distinct extensions named in the per-page skipped-file diagnostic. */ +const MAX_LOGGED_SKIPPED_EXTENSIONS = 10 + const GRAPH_API_ORIGIN = 'https://graph.microsoft.com' const GRAPH_BASE_URL = `${GRAPH_API_ORIGIN}/v1.0` @@ -85,19 +77,9 @@ interface OneDriveListResponse { } /** - * Checks whether a file has a supported text extension. + * Downloads the raw bytes of a OneDrive file. */ -function isSupportedTextFile(name: string): boolean { - const dotIndex = name.lastIndexOf('.') - if (dotIndex === -1) return false - const ext = name.slice(dotIndex).toLowerCase() - return SUPPORTED_EXTENSIONS.has(ext) -} - -/** - * Downloads the raw content of a OneDrive file. - */ -async function downloadFileContent(accessToken: string, fileId: string): Promise { +async function downloadFileContent(accessToken: string, fileId: string): Promise { const url = `${GRAPH_BASE_URL}/me/drive/items/${encodeURIComponent(fileId)}/content` const response = await fetchWithRetry(url, { @@ -114,25 +96,20 @@ async function downloadFileContent(accessToken: string, fileId: string): Promise if (!buffer) { throw new ConnectorFileTooLargeError(MAX_FILE_SIZE) } - return buffer.toString('utf8') + return buffer } /** - * Fetches file content, converting HTML to plain text when applicable. + * Fetches a file and extracts its indexable text — a UTF-8 decode for text + * formats, and the shared knowledge-base parsers for Office documents and PDFs. */ async function fetchFileContent( accessToken: string, fileId: string, fileName: string ): Promise { - const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase() - const raw = await downloadFileContent(accessToken, fileId) - - if (ext === '.html' || ext === '.htm') { - return htmlToPlainText(raw) - } - - return raw + const buffer = await downloadFileContent(accessToken, fileId) + return extractConnectorText(buffer, fileName) } /** @@ -282,15 +259,39 @@ export const onedriveConnector: ConnectorConfig = { const items = data.value || [] const files: OneDriveItem[] = [] + /** + * Extensions this connector cannot index, tallied per page. A folder of + * unsupported files otherwise syncs as "success, 0 documents", which reads + * exactly like a wrong folder path — the failure mode this log exists for. + * Unsupported files are counted rather than turned into `failed` document + * rows, so a drive full of images does not fill the knowledge base with noise. + */ + const skippedExtensions = new Map() + for (const item of items) { if (item.folder) { state.folderStack.push(item.id) - } else if (item.file && isSupportedTextFile(item.name)) { - // Keep oversized files; they are surfaced as skipped (failed) docs below. - files.push(item) + } else if (item.file) { + if (isIndexableConnectorFile(item.name)) { + // Keep oversized files; they are surfaced as skipped (failed) docs below. + files.push(item) + } else { + const extension = connectorFileExtension(item.name) ?? '(none)' + skippedExtensions.set(extension, (skippedExtensions.get(extension) ?? 0) + 1) + } } } + if (skippedExtensions.size > 0) { + let skippedCount = 0 + for (const count of skippedExtensions.values()) skippedCount += count + logger.info('Skipped OneDrive files with unsupported extensions', { + folderId: state.currentFolder ?? 'root', + skippedCount, + extensions: Array.from(skippedExtensions.keys()).slice(0, MAX_LOGGED_SKIPPED_EXTENSIONS), + }) + } + const stubs = files.map((item) => stubOrSkipBySize(fileToStub(item), item.size, MAX_FILE_SIZE) ) @@ -373,7 +374,7 @@ export const onedriveConnector: ConnectorConfig = { const item = (await response.json()) as OneDriveItem - if (!item.file || !isSupportedTextFile(item.name)) return null + if (!item.file || !isIndexableConnectorFile(item.name)) return null try { const content = await fetchFileContent(accessToken, item.id, item.name) @@ -386,6 +387,13 @@ export const onedriveConnector: ConnectorConfig = { logger.info('Skipping oversized OneDrive file', { fileId: item.id, name: item.name }) return markSkipped(fileToStub(item), sizeLimitSkipReason(error.limitBytes)) } + if (error instanceof ConnectorTextExtractionError) { + logger.info('Skipping OneDrive file with no extractable text', { + fileId: item.id, + name: item.name, + }) + return markSkipped(fileToStub(item), extractionFailedSkipReason(error.extension)) + } /** * A transport or Graph failure that survived `fetchWithRetry`. Returning * `null` would drop the file from the run with no `failed` row and no error diff --git a/apps/sim/connectors/sharepoint/sharepoint.test.ts b/apps/sim/connectors/sharepoint/sharepoint.test.ts index cbefdc63aac..9d9a048eef1 100644 --- a/apps/sim/connectors/sharepoint/sharepoint.test.ts +++ b/apps/sim/connectors/sharepoint/sharepoint.test.ts @@ -3,12 +3,16 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() })) +const { mockFetchWithRetry, mockParseBuffer } = vi.hoisted(() => ({ + mockFetchWithRetry: vi.fn(), + mockParseBuffer: vi.fn(), +})) vi.mock('@/lib/knowledge/documents/utils', () => ({ fetchWithRetry: mockFetchWithRetry, VALIDATE_RETRY_OPTIONS: {}, })) +vi.mock('@/lib/file-parsers', () => ({ parseBuffer: mockParseBuffer })) vi.mock('@/components/icons', () => ({ MicrosoftSharepointIcon: () => null })) import { @@ -27,6 +31,8 @@ const POLICIES_DRIVE_ID = 'b!policies' interface GraphRoute { status?: number body?: unknown + /** Serve `body` as bytes, for the `/content` endpoint the downloader reads. */ + raw?: boolean } /** Folder-shaped drive item for children listings. */ @@ -49,6 +55,9 @@ function mockGraph(routes: Record) { status, json: async () => route.body, text: async () => JSON.stringify(route.body ?? {}), + /** `readBodyWithLimit` falls back to this when there is no stream body. */ + arrayBuffer: async () => + Buffer.from(route.raw ? String(route.body ?? '') : JSON.stringify(route.body ?? {})), } as unknown as Response }) return requested @@ -411,6 +420,47 @@ describe('listDocuments', () => { expect(syncContext.listingCapped).toBeUndefined() }) + /** + * The reported failure: a document library of Office SOPs synced as + * "success, 0 documents" because the listing filter accepted only plain text, + * which is indistinguishable from a wrong folder path. + */ + it('lists Office documents and PDFs alongside text files', async () => { + mockGraph( + childrenRoute(DEFAULT_DRIVE_ID, null, [ + file('f1', 'Market Data SOP.docx'), + file('f2', 'Vendor Contract.pdf'), + file('f3', 'User List.xlsx'), + file('f4', 'Overview.pptx'), + file('f5', 'notes.txt'), + ]) + ) + + const result = await list(undefined, listContext()) + + expect(result.documents.map((doc) => doc.title)).toEqual([ + 'Market Data SOP.docx', + 'Vendor Contract.pdf', + 'User List.xlsx', + 'Overview.pptx', + 'notes.txt', + ]) + }) + + it('still excludes files with no extractable text', async () => { + mockGraph( + childrenRoute(DEFAULT_DRIVE_ID, null, [ + file('f1', 'diagram.png'), + file('f2', 'recording.mp4'), + file('f3', 'notes.txt'), + ]) + ) + + const result = await list(undefined, listContext()) + + expect(result.documents.map((doc) => doc.externalId)).toEqual(['f3']) + }) + it('builds a metadata-only contentHash that getDocument can reproduce', async () => { mockGraph(childrenRoute(DEFAULT_DRIVE_ID, null, [file('f1', 'a.txt')])) @@ -421,6 +471,73 @@ describe('listDocuments', () => { }) }) +describe('getDocument content extraction', () => { + function itemRoute(itemId: string, name: string) { + return { + [`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/items/${itemId}?$select=${ITEM_SELECT}`]: { + body: file(itemId, name), + }, + } + } + + /** The content endpoint is fetched directly, not through the JSON `graphGet`. */ + function contentRoute(itemId: string, body: string) { + return { + [`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/items/${itemId}/content`]: { body, raw: true }, + } + } + + function get(externalId: string) { + return sharepointConnector.getDocument!( + 'token', + { siteUrl: SITE_URL }, + externalId, + listContext() + ) + } + + it('indexes the parsed text of an Office document', async () => { + mockGraph({ ...itemRoute('f1', 'SOP.docx'), ...contentRoute('f1', 'ignored') }) + mockParseBuffer.mockResolvedValue({ + content: 'Approved vendor list', + metadata: { extractionMethod: 'mammoth' }, + }) + + const doc = await get('f1') + + expect(doc?.content).toBe('Approved vendor list') + expect(doc?.skippedReason).toBeUndefined() + expect(doc?.contentDeferred).toBe(false) + }) + + /** + * A degraded extraction must become a visible `failed` row, not a silent drop + * and not indexed placeholder text — the same treatment oversized files get. + */ + it('surfaces a degraded extraction as a skipped document with an actionable reason', async () => { + mockGraph({ ...itemRoute('f2', 'Deck.ppt'), ...contentRoute('f2', 'ole2') }) + mockParseBuffer.mockResolvedValue({ + content: 'Unable to extract text from PowerPoint file.', + metadata: { extractionMethod: 'fallback', degraded: true }, + }) + + const doc = await get('f2') + + expect(doc?.content).toBe('') + expect(doc?.skippedReason).toContain('PPTX') + expect(doc?.externalId).toBe('f2') + }) + + it('reads a text file without invoking a parser', async () => { + mockGraph({ ...itemRoute('f3', 'notes.txt'), ...contentRoute('f3', 'plain notes') }) + + const doc = await get('f3') + + expect(doc?.content).toBe('plain notes') + expect(mockParseBuffer).not.toHaveBeenCalled() + }) +}) + describe('serverRelativePathFromUrl', () => { it('strips the site prefix from a site-scoped URL', () => { expect( diff --git a/apps/sim/connectors/sharepoint/sharepoint.ts b/apps/sim/connectors/sharepoint/sharepoint.ts index ac29865e2bb..c257ef4b3b1 100644 --- a/apps/sim/connectors/sharepoint/sharepoint.ts +++ b/apps/sim/connectors/sharepoint/sharepoint.ts @@ -6,7 +6,11 @@ import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/ import { CONNECTOR_MAX_FILE_BYTES, ConnectorFileTooLargeError, - htmlToPlainText, + ConnectorTextExtractionError, + connectorFileExtension, + extractConnectorText, + extractionFailedSkipReason, + isIndexableConnectorFile, isSkippedDocument, markSkipped, parseTagDate, @@ -21,23 +25,11 @@ const logger = createLogger('SharePointConnector') const GRAPH_API_ORIGIN = 'https://graph.microsoft.com' const GRAPH_BASE = `${GRAPH_API_ORIGIN}/v1.0` -const SUPPORTED_TEXT_EXTENSIONS = new Set([ - '.txt', - '.md', - '.html', - '.htm', - '.csv', - '.json', - '.xml', - '.yaml', - '.yml', - '.log', - '.rst', - '.tsv', -]) - const MAX_DOWNLOAD_SIZE = CONNECTOR_MAX_FILE_BYTES +/** Distinct extensions named in the per-page skipped-file diagnostic. */ +const MAX_LOGGED_SKIPPED_EXTENSIONS = 10 + /** * The exact driveItem fields the stub is built from. Graph returns the full * driveItem otherwise, which is an order of magnitude larger per item. @@ -94,15 +86,6 @@ interface ResolvedFolderTarget { type RetryOptions = Parameters[2] -/** - * Returns true when the file extension is in the supported text set. - */ -function isSupportedTextFile(name: string): boolean { - const dotIndex = name.lastIndexOf('.') - if (dotIndex === -1) return false - return SUPPORTED_TEXT_EXTENSIONS.has(name.slice(dotIndex).toLowerCase()) -} - /** * Asserts a request URL points at Microsoft Graph before it is followed with the * bearer token in the `Authorization` header. Several callers pass a @@ -197,14 +180,14 @@ async function resolveSiteId( } /** - * Downloads the text content of a drive item. + * Downloads the raw bytes of a drive item. */ async function downloadFileContent( accessToken: string, driveId: string, itemId: string, fileName: string -): Promise { +): Promise { const url = `${GRAPH_BASE}/drives/${driveId}/items/${encodeURIComponent(itemId)}/content` const response = await fetchWithRetry(url, { @@ -224,11 +207,12 @@ async function downloadFileContent( if (!buffer) { throw new ConnectorFileTooLargeError(MAX_DOWNLOAD_SIZE) } - return buffer.toString('utf8') + return buffer } /** - * Fetches file content, applying HTML-to-text conversion for .html files. + * Fetches a file and extracts its indexable text — a UTF-8 decode for text + * formats, and the shared knowledge-base parsers for Office documents and PDFs. */ async function fetchFileContent( accessToken: string, @@ -236,11 +220,8 @@ async function fetchFileContent( itemId: string, fileName: string ): Promise { - const raw = await downloadFileContent(accessToken, driveId, itemId, fileName) - if (fileName.toLowerCase().endsWith('.html') || fileName.toLowerCase().endsWith('.htm')) { - return htmlToPlainText(raw) - } - return raw + const buffer = await downloadFileContent(accessToken, driveId, itemId, fileName) + return extractConnectorText(buffer, fileName) } /** @@ -793,15 +774,39 @@ export const sharepointConnector: ConnectorConfig = { const subfolders: string[] = [] const files: DriveItem[] = [] + /** + * Extensions this connector cannot index, tallied per page. A folder of + * unsupported files otherwise syncs as "success, 0 documents", which reads + * exactly like a wrong folder path — the failure mode this log exists for. + * Unsupported files are counted rather than turned into `failed` document + * rows, so a library of images does not fill the knowledge base with noise. + */ + const skippedExtensions = new Map() + for (const item of data.value) { if (item.folder) { subfolders.push(item.id) - } else if (item.file && isSupportedTextFile(item.name)) { - // Keep oversized files; they are surfaced as skipped (failed) docs below. - files.push(item) + } else if (item.file) { + if (isIndexableConnectorFile(item.name)) { + // Keep oversized files; they are surfaced as skipped (failed) docs below. + files.push(item) + } else { + const extension = connectorFileExtension(item.name) ?? '(none)' + skippedExtensions.set(extension, (skippedExtensions.get(extension) ?? 0) + 1) + } } } + if (skippedExtensions.size > 0) { + let skippedCount = 0 + for (const count of skippedExtensions.values()) skippedCount += count + logger.info('Skipped SharePoint files with unsupported extensions', { + folderId: state.currentFolder ?? 'root', + skippedCount, + extensions: Array.from(skippedExtensions.keys()).slice(0, MAX_LOGGED_SKIPPED_EXTENSIONS), + }) + } + // Push subfolders onto the stack for depth-first traversal state.folderStack.push(...subfolders) @@ -915,7 +920,7 @@ export const sharepointConnector: ConnectorConfig = { const item = (await response.json()) as DriveItem - if (!item.file || !isSupportedTextFile(item.name)) { + if (!item.file || !isIndexableConnectorFile(item.name)) { return null } @@ -933,6 +938,16 @@ export const sharepointConnector: ConnectorConfig = { sizeLimitSkipReason(error.limitBytes) ) } + if (error instanceof ConnectorTextExtractionError) { + logger.info('Skipping SharePoint file with no extractable text', { + fileId: item.id, + name: item.name, + }) + return markSkipped( + itemToStub(item, siteName ?? siteUrl), + extractionFailedSkipReason(error.extension) + ) + } /** * A transport or Graph failure that survived `fetchWithRetry`. Returning * `null` would drop the file from the run with no `failed` row and no error diff --git a/apps/sim/connectors/utils.test.ts b/apps/sim/connectors/utils.test.ts index 121a0ec99c8..9474414bceb 100644 --- a/apps/sim/connectors/utils.test.ts +++ b/apps/sim/connectors/utils.test.ts @@ -1,9 +1,11 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExternalDocument } from '@/connectors/types' +const { mockParseBuffer } = vi.hoisted(() => ({ mockParseBuffer: vi.fn() })) + vi.mock('@/components/icons', () => ({ JiraIcon: () => null, ConfluenceIcon: () => null, @@ -29,6 +31,7 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({ fetchWithRetry: vi.fn(), VALIDATE_RETRY_OPTIONS: {}, })) +vi.mock('@/lib/file-parsers', () => ({ parseBuffer: mockParseBuffer })) vi.mock('@/tools/jira/utils', () => ({ extractAdfText: vi.fn(), getJiraCloudId: vi.fn() })) vi.mock('@/tools/confluence/utils', () => ({ getConfluenceCloudId: vi.fn() })) vi.mock('@/tools/jsm/utils', () => ({ @@ -62,7 +65,11 @@ import { sentryConnector } from '@/connectors/sentry/sentry' import { typeformConnector } from '@/connectors/typeform/typeform' import { ConnectorFileTooLargeError, + ConnectorTextExtractionError, + extractConnectorText, + extractionFailedSkipReason, htmlToPlainText, + isIndexableConnectorFile, isSkippedDocument, markSkipped, readBodyWithLimit, @@ -1365,3 +1372,184 @@ describe('htmlToPlainText entity decoding', () => { expect(htmlToPlainText('

© ¬real;

')).toBe('© ¬real;') }) }) + +describe('isIndexableConnectorFile', () => { + it('accepts the Office and PDF formats the knowledge base can parse', () => { + for (const name of [ + 'sop.pdf', + 'sop.doc', + 'sop.docx', + 'sheet.xls', + 'sheet.xlsx', + 'deck.ppt', + 'deck.pptx', + ]) { + expect(isIndexableConnectorFile(name)).toBe(true) + } + }) + + it('still accepts the plain-text formats connectors already synced', () => { + for (const name of ['a.txt', 'a.md', 'a.html', 'a.htm', 'a.csv', 'a.log', 'a.tsv', 'a.rst']) { + expect(isIndexableConnectorFile(name)).toBe(true) + } + }) + + /** + * A document library holds the whole family, not just the headline extension: + * macro-enabled and template variants are the same OOXML packages, `xlsb` is the + * binary workbook, and the OpenDocument trio covers LibreOffice/Google exports. + */ + it('accepts macro-enabled, template, binary and OpenDocument variants', () => { + for (const name of [ + 'report.docm', + 'letterhead.dotx', + 'model.xlsm', + 'model.xlsb', + 'budget.xltx', + 'deck.pptm', + 'brand.potx', + 'notes.odt', + 'sheet.ods', + 'slides.odp', + ]) { + expect(isIndexableConnectorFile(name)).toBe(true) + } + }) + + it('rejects formats with no text to extract', () => { + for (const name of ['logo.png', 'clip.mp4', 'archive.zip', 'binary.exe']) { + expect(isIndexableConnectorFile(name)).toBe(false) + } + }) + + /** + * No bundled library extracts RTF. `DocParser`'s plaintext branch would accept + * it and pass its control words through as prose, so it stays out of the set and + * is reported as an unsupported extension instead. + */ + it('rejects rtf rather than indexing its control words as prose', () => { + expect(isIndexableConnectorFile('policy.rtf')).toBe(false) + }) + + it('rejects a name with no extension, and one ending in a bare dot', () => { + expect(isIndexableConnectorFile('README')).toBe(false) + expect(isIndexableConnectorFile('trailing.')).toBe(false) + }) + + it('ignores extension case', () => { + expect(isIndexableConnectorFile('SOP.DOCX')).toBe(true) + }) +}) + +describe('extractConnectorText', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('routes a binary document format through the shared parsers', async () => { + mockParseBuffer.mockResolvedValue({ content: 'extracted docx text' }) + const buffer = Buffer.from('PK binary') + + const content = await extractConnectorText(buffer, 'Market Data SOP.docx') + + expect(content).toBe('extracted docx text') + expect(mockParseBuffer).toHaveBeenCalledWith(buffer, 'docx') + }) + + it('passes each parsed variant to the parser under its own extension', async () => { + mockParseBuffer.mockResolvedValue({ content: 'text' }) + + for (const extension of ['docm', 'xlsm', 'xlsb', 'pptm', 'odt', 'ods', 'odp']) { + await extractConnectorText(Buffer.from('PK'), `file.${extension}`) + expect(mockParseBuffer).toHaveBeenLastCalledWith(expect.any(Buffer), extension) + } + }) + + /** + * The formats that synced before this change must keep taking the byte-for-byte + * identical path. Sending `.csv` through `CsvParser` would silently reformat + * every already-indexed connector document on its next re-index. + */ + it('decodes already-supported text formats as UTF-8 without invoking a parser', async () => { + for (const name of ['notes.txt', 'data.csv', 'config.yaml', 'rows.tsv', 'feed.xml']) { + const content = await extractConnectorText(Buffer.from('a,b'), name) + expect(content).toBe('a,b') + } + expect(mockParseBuffer).not.toHaveBeenCalled() + }) + + it('reduces HTML to plain text rather than parsing it', async () => { + const content = await extractConnectorText(Buffer.from('

Hello world

'), 'page.htm') + + expect(content).toBe('Hello world') + expect(mockParseBuffer).not.toHaveBeenCalled() + }) + + it('falls back to a UTF-8 decode for an extension with no parser', async () => { + const content = await extractConnectorText(Buffer.from('plain'), 'notes.unknownext') + + expect(content).toBe('plain') + expect(mockParseBuffer).not.toHaveBeenCalled() + }) + + it('propagates a parser failure so the sync records a failed document', async () => { + mockParseBuffer.mockRejectedValue(new Error('corrupt archive')) + + await expect(extractConnectorText(Buffer.from('bad'), 'broken.docx')).rejects.toThrow( + 'corrupt archive' + ) + }) + + /** + * `DocParser` and `PptxParser` never throw by design: on a legacy binary or an + * image-only deck they return scraped ZIP internals or an English placeholder + * sentence so an interactive upload still shows the user something. Indexing + * that would embed junk, so a degraded result must not become content. + */ + it('rejects a degraded extraction instead of indexing placeholder text', async () => { + mockParseBuffer.mockResolvedValue({ + content: 'Unable to extract text from PowerPoint file. Please ensure the file contains text.', + metadata: { extractionMethod: 'fallback', degraded: true }, + }) + + await expect(extractConnectorText(Buffer.from('ole2'), 'Deck.ppt')).rejects.toThrow( + ConnectorTextExtractionError + ) + }) + + it('rejects an extraction that produced only whitespace', async () => { + mockParseBuffer.mockResolvedValue({ content: ' \n ', metadata: {} }) + + await expect(extractConnectorText(Buffer.from('pdf'), 'scanned.pdf')).rejects.toThrow( + ConnectorTextExtractionError + ) + }) + + it('carries the extension so the caller can name the format in its skip reason', async () => { + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + + await expect(extractConnectorText(Buffer.from('x'), 'Deck.PPT')).rejects.toMatchObject({ + extension: 'ppt', + fileName: 'Deck.PPT', + }) + }) + + it('does not apply the degraded check to text formats', async () => { + const content = await extractConnectorText(Buffer.from(' '), 'blank.txt') + + expect(content).toBe(' ') + expect(mockParseBuffer).not.toHaveBeenCalled() + }) +}) + +describe('extractionFailedSkipReason', () => { + it('tells the user which modern format to re-save a legacy file as', () => { + expect(extractionFailedSkipReason('doc')).toContain('DOCX') + expect(extractionFailedSkipReason('ppt')).toContain('PPTX') + expect(extractionFailedSkipReason('xls')).toContain('XLSX') + }) + + it('explains the likely cause for a modern format', () => { + expect(extractionFailedSkipReason('pdf')).toMatch(/scanned, image-only, or password-protected/) + }) +}) diff --git a/apps/sim/connectors/utils.ts b/apps/sim/connectors/utils.ts index 5e04a03619a..930b5e32c0b 100644 --- a/apps/sim/connectors/utils.ts +++ b/apps/sim/connectors/utils.ts @@ -134,6 +134,162 @@ export function htmlToPlainText(html: string): string { return text.replace(/\s+/g, ' ').trim() } +/** + * Extensions a file-based connector reads straight off the wire as UTF-8. These are + * the formats connectors have always accepted, and they deliberately keep bypassing + * the knowledge-base parsers: routing `.csv` through `CsvParser` or `.json` through + * the JSON parser would reformat content that is already indexed, changing what is + * embedded for every existing connector document on its next re-index. + */ +const CONNECTOR_TEXT_EXTENSIONS = [ + 'txt', + 'md', + 'html', + 'htm', + 'csv', + 'json', + 'jsonl', + 'xml', + 'yaml', + 'yml', + 'log', + 'rst', + 'tsv', +] as const + +/** + * Binary document formats extracted through the shared knowledge-base file parsers. + * Listing them separately from {@link CONNECTOR_TEXT_EXTENSIONS} is what keeps this + * additive: a format that synced yesterday takes exactly the path it took yesterday. + * + * The set covers the variants a real document library actually holds, not just the + * headline extension of each family — macro-enabled (`docm`, `xlsm`, `pptm`) and + * template (`dotx`, `xltx`, `potx`) files are the same OOXML packages, the binary + * workbook (`xlsb`) and legacy BIFF workbook (`xls`) are read by SheetJS, and the + * OpenDocument trio covers LibreOffice and Google Docs exports. + * + * `rtf` is deliberately absent: no bundled library extracts it, and `DocParser` + * would pass its control words through as if they were prose. See + * {@link CONNECTOR_INDEXABLE_EXTENSIONS} for how an unsupported format surfaces. + */ +const CONNECTOR_PARSED_EXTENSIONS = [ + 'pdf', + 'doc', + 'docx', + 'docm', + 'dotx', + 'xls', + 'xlsx', + 'xlsm', + 'xlsb', + 'xltx', + 'ppt', + 'pptx', + 'pptm', + 'potx', + 'odt', + 'ods', + 'odp', +] as const + +/** + * Every extension a file-based connector will download and index. + * + * Previously each connector carried its own text-only whitelist, so an Office + * document in a synced folder was dropped during listing — no document, no failed + * row, no log line. A library of `.docx` SOPs therefore synced as "success, 0 + * documents", which is indistinguishable from a wrong folder path. + */ +export const CONNECTOR_INDEXABLE_EXTENSIONS: ReadonlySet = new Set([ + ...CONNECTOR_TEXT_EXTENSIONS, + ...CONNECTOR_PARSED_EXTENSIONS, +]) + +/** Extracts a lowercased, dotless extension from a file name. */ +export function connectorFileExtension(fileName: string): string | undefined { + const dotIndex = fileName.lastIndexOf('.') + if (dotIndex === -1 || dotIndex === fileName.length - 1) return undefined + return fileName.slice(dotIndex + 1).toLowerCase() +} + +/** + * Reports whether a connector should download and index a file, based on its name. + */ +export function isIndexableConnectorFile(fileName: string): boolean { + const extension = connectorFileExtension(fileName) + return extension !== undefined && CONNECTOR_INDEXABLE_EXTENSIONS.has(extension) +} + +/** + * Raised when a binary document yielded no text a search index should hold — + * either the parser produced nothing, or it reported a degraded extraction whose + * "content" is scraped bytes or a placeholder message. Callers surface it as a + * skipped document, the same way {@link ConnectorFileTooLargeError} is handled, + * so the file stays visible with an actionable reason instead of polluting the + * index or vanishing. + */ +export class ConnectorTextExtractionError extends Error { + constructor( + readonly fileName: string, + readonly extension: string + ) { + super(`No text could be extracted from "${fileName}"`) + this.name = 'ConnectorTextExtractionError' + } +} + +/** + * Human-readable skip reason for a document whose text could not be extracted. + * Legacy formats get the concrete remedy — re-saving genuinely fixes them, because + * the modern container is one the bundled parsers read. + */ +export function extractionFailedSkipReason(extension: string): string { + const legacyFormats: Record = { doc: 'DOCX', ppt: 'PPTX', xls: 'XLSX' } + const modernFormat = legacyFormats[extension] + return modernFormat + ? `No text could be extracted from this ${extension.toUpperCase()} file. Re-save it as ${modernFormat} to index it.` + : 'No text could be extracted from this file — it may be scanned, image-only, or password-protected.' +} + +/** + * Converts a downloaded file body to indexable text. + * + * Text formats are decoded as UTF-8 (with HTML additionally reduced to plain text), + * and binary document formats go through `parseBuffer`, which applies the OOXML + * zip-bomb guard and each parser's own extraction limits. An extension with no + * parser falls back to a UTF-8 decode rather than failing the file. + * + * A parsed format that yields no usable text throws {@link ConnectorTextExtractionError} + * rather than returning what the parser handed back. The `doc` and `ppt` parsers + * never throw by design — on a legacy binary or an image-only deck they return a + * placeholder sentence or raw ZIP internals, which an interactive upload can show + * a user but an automated sync must never embed. + */ +export async function extractConnectorText(buffer: Buffer, fileName: string): Promise { + const extension = connectorFileExtension(fileName) + + if (extension === 'html' || extension === 'htm') { + return htmlToPlainText(buffer.toString('utf8')) + } + + if (extension && (CONNECTOR_PARSED_EXTENSIONS as readonly string[]).includes(extension)) { + /** + * Imported here rather than at module scope: every connector imports this + * file, but only the file-based ones ever reach a binary document, and the + * parser registry pulls in SheetJS and friends. Mirrors how the parsers + * themselves defer `officeparser`/`mammoth`/`unpdf`. + */ + const { parseBuffer } = await import('@/lib/file-parsers') + const result = await parseBuffer(buffer, extension) + if (result.metadata?.degraded || !result.content.trim()) { + throw new ConnectorTextExtractionError(fileName, extension) + } + return result.content + } + + return buffer.toString('utf8') +} + /** * Computes a SHA-256 hash of the given content string. * Used by connectors for change detection during sync. diff --git a/apps/sim/lib/file-parsers/doc-parser.ts b/apps/sim/lib/file-parsers/doc-parser.ts index f03d3a45955..24b629cad96 100644 --- a/apps/sim/lib/file-parsers/doc-parser.ts +++ b/apps/sim/lib/file-parsers/doc-parser.ts @@ -131,6 +131,7 @@ export class DocParser implements FileParser { content, metadata: { extractionMethod: 'fallback', + degraded: true, characterCount: content.length, warning: 'Basic text extraction used. For better results, convert to DOCX format.', }, diff --git a/apps/sim/lib/file-parsers/index.ts b/apps/sim/lib/file-parsers/index.ts index d53298cea0a..4c50c53e4f8 100644 --- a/apps/sim/lib/file-parsers/index.ts +++ b/apps/sim/lib/file-parsers/index.ts @@ -1,132 +1,84 @@ import { existsSync } from 'fs' import path from 'path' import { createLogger } from '@sim/logger' +import { CsvParser } from '@/lib/file-parsers/csv-parser' +import { DocParser } from '@/lib/file-parsers/doc-parser' +import { DocxParser } from '@/lib/file-parsers/docx-parser' +import { HtmlParser } from '@/lib/file-parsers/html-parser' +import { + parseJSON, + parseJSONBuffer, + parseJSONL, + parseJSONLBuffer, +} from '@/lib/file-parsers/json-parser' +import { MdParser } from '@/lib/file-parsers/md-parser' +import { OpenDocumentParser } from '@/lib/file-parsers/opendocument-parser' +import { PdfParser } from '@/lib/file-parsers/pdf-parser' +import { PptxParser } from '@/lib/file-parsers/pptx-parser' +import { TxtParser } from '@/lib/file-parsers/txt-parser' import type { FileParseResult, FileParser, SupportedFileType } from '@/lib/file-parsers/types' +import { XlsxParser } from '@/lib/file-parsers/xlsx-parser' +import { parseYAML, parseYAMLBuffer } from '@/lib/file-parsers/yaml-parser' import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard' const logger = createLogger('FileParser') -let parserInstances: Record | null = null - /** - * Get parser instances with lazy initialization + * Extension → parser. Several extensions deliberately share one parser because + * they are the same container: + * + * - `docm`/`dotx` are the WordprocessingML package `docx` uses; mammoth reads + * `word/document.xml` without consulting the package's content type. + * - `xlsm`/`xlsb`/`xltx`/`xls`/`ods` are all read natively by SheetJS. `ods` is + * treated as a spreadsheet rather than routed to {@link OpenDocumentParser} so + * its output keeps per-sheet structure instead of one flat text run. + * - `pptm`/`potx` are the PresentationML package `pptx` uses. `ppt` is the legacy + * OLE binary that no bundled library reads; it is mapped here so it degrades + * through the parser's own reporting rather than looking simply unsupported. + * + * Every parser module is imported statically and every dependency is a regular + * (non-optional) one, so a broken install fails loudly at import. This previously + * used `require()` inside per-parser `try/catch` blocks that only logged, which + * meant a resolution failure produced a silently **empty** registry and turned + * every format into `Unsupported file type` — an outcome indistinguishable from a + * genuinely unsupported extension. The heavy extraction libraries are still loaded + * on demand inside the individual parsers. + * + * A `Map` rather than an object literal: the extension is caller-supplied, and a + * plain object would resolve inherited keys, so `PARSERS['constructor']` would hand + * back `Object` and route the request to a "parser" with no parse methods. */ -function getParserInstances(): Record { - if (parserInstances === null) { - parserInstances = {} - - try { - try { - logger.info('Loading PDF parser...') - const { PdfParser } = require('@/lib/file-parsers/pdf-parser') - parserInstances.pdf = new PdfParser() - logger.info('PDF parser loaded successfully') - } catch (error) { - logger.error('Failed to load PDF parser:', error) - } - - try { - const { CsvParser } = require('@/lib/file-parsers/csv-parser') - parserInstances.csv = new CsvParser() - logger.info('Loaded streaming CSV parser with csv-parse library') - } catch (error) { - logger.error('Failed to load streaming CSV parser:', error) - } - - try { - const { DocxParser } = require('@/lib/file-parsers/docx-parser') - parserInstances.docx = new DocxParser() - } catch (error) { - logger.error('Failed to load DOCX parser:', error) - } - - try { - const { DocParser } = require('@/lib/file-parsers/doc-parser') - parserInstances.doc = new DocParser() - } catch (error) { - logger.error('Failed to load DOC parser:', error) - } - - try { - const { TxtParser } = require('@/lib/file-parsers/txt-parser') - parserInstances.txt = new TxtParser() - } catch (error) { - logger.error('Failed to load TXT parser:', error) - } - - try { - const { MdParser } = require('@/lib/file-parsers/md-parser') - parserInstances.md = new MdParser() - } catch (error) { - logger.error('Failed to load MD parser:', error) - } - - try { - const { XlsxParser } = require('@/lib/file-parsers/xlsx-parser') - parserInstances.xlsx = new XlsxParser() - parserInstances.xls = new XlsxParser() - logger.info('Loaded XLSX parser') - } catch (error) { - logger.error('Failed to load XLSX parser:', error) - } - - try { - const { PptxParser } = require('@/lib/file-parsers/pptx-parser') - parserInstances.pptx = new PptxParser() - parserInstances.ppt = new PptxParser() - } catch (error) { - logger.error('Failed to load PPTX parser:', error) - } - - try { - const { HtmlParser } = require('@/lib/file-parsers/html-parser') - parserInstances.html = new HtmlParser() - parserInstances.htm = new HtmlParser() - } catch (error) { - logger.error('Failed to load HTML parser:', error) - } - - try { - const { - parseJSON, - parseJSONBuffer, - parseJSONL, - parseJSONLBuffer, - } = require('@/lib/file-parsers/json-parser') - parserInstances.json = { - parseFile: parseJSON, - parseBuffer: parseJSONBuffer, - } - parserInstances.jsonl = { - parseFile: parseJSONL, - parseBuffer: parseJSONLBuffer, - } - logger.info('Loaded JSON/JSONL parser') - } catch (error) { - logger.error('Failed to load JSON parser:', error) - } - - try { - const { parseYAML, parseYAMLBuffer } = require('@/lib/file-parsers/yaml-parser') - parserInstances.yaml = { - parseFile: parseYAML, - parseBuffer: parseYAMLBuffer, - } - parserInstances.yml = { - parseFile: parseYAML, - parseBuffer: parseYAMLBuffer, - } - logger.info('Loaded YAML parser') - } catch (error) { - logger.error('Failed to load YAML parser:', error) - } - } catch (error) { - logger.error('Error loading file parsers:', error) - } - } - - return parserInstances -} +const PARSERS = new Map([ + ['pdf', new PdfParser()], + ['csv', new CsvParser()], + ['doc', new DocParser()], + ['docx', new DocxParser()], + ['docm', new DocxParser()], + ['dotx', new DocxParser()], + ['txt', new TxtParser()], + ['md', new MdParser()], + ['xlsx', new XlsxParser()], + ['xls', new XlsxParser()], + ['xlsm', new XlsxParser()], + ['xlsb', new XlsxParser()], + ['xltx', new XlsxParser()], + ['ods', new XlsxParser()], + ['pptx', new PptxParser()], + ['ppt', new PptxParser()], + ['pptm', new PptxParser()], + ['potx', new PptxParser()], + ['odt', new OpenDocumentParser()], + ['odp', new OpenDocumentParser()], + ['html', new HtmlParser()], + ['htm', new HtmlParser()], + ['json', { parseFile: parseJSON, parseBuffer: parseJSONBuffer }], + ['jsonl', { parseFile: parseJSONL, parseBuffer: parseJSONLBuffer }], + ['yaml', { parseFile: parseYAML, parseBuffer: parseYAMLBuffer }], + ['yml', { parseFile: parseYAML, parseBuffer: parseYAMLBuffer }], +]) + +/** Extensions with a registered parser, for error messages. */ +const SUPPORTED_EXTENSIONS_TEXT = [...PARSERS.keys()].join(', ') /** * Parse a file based on its extension @@ -144,19 +96,14 @@ export async function parseFile(filePath: string): Promise { } const extension = path.extname(filePath).toLowerCase().substring(1) - logger.info('Attempting to parse file with extension:', extension) + const parser = PARSERS.get(extension) - const parsers = getParserInstances() - - if (!Object.keys(parsers).includes(extension)) { - logger.info('No parser found for extension:', extension) + if (!parser) { throw new Error( - `Unsupported file type: ${extension}. Supported types are: ${Object.keys(parsers).join(', ')}` + `Unsupported file type: ${extension}. Supported types are: ${SUPPORTED_EXTENSIONS_TEXT}` ) } - logger.info('Using parser for extension:', extension) - const parser = parsers[extension] return await parser.parseFile(filePath) } catch (error) { logger.error('File parsing error:', error) @@ -188,24 +135,19 @@ export async function parseBuffer(buffer: Buffer, extension: string): Promise { + if (!filePath) { + throw new Error('No file path provided') + } + + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`) + } + + const buffer = await readFile(filePath) + return this.parseBuffer(buffer) + } + + async parseBuffer(buffer: Buffer): Promise { + if (!buffer || buffer.length === 0) { + throw new Error('Empty buffer provided') + } + + /** + * The container is a ZIP, so the decompression-bomb guard applies exactly as + * it does for OOXML — and it must run before officeparser inflates anything. + */ + assertOoxmlArchiveWithinLimits(buffer) + + const { parseOfficeAsync } = await import('officeparser') + + let extracted: string + try { + const result = await parseOfficeAsync(buffer) + extracted = typeof result === 'string' ? result : '' + } catch (error) { + logger.error('OpenDocument parsing failed', { error: (error as Error).message }) + throw new Error(`Failed to parse OpenDocument file: ${(error as Error).message}`) + } + + const content = sanitizeTextForUTF8(extracted.trim()) + if (!content) { + throw new Error('Failed to extract text from OpenDocument file') + } + + return { + content, + metadata: { + characterCount: content.length, + extractionMethod: 'officeparser', + }, + } + } +} diff --git a/apps/sim/lib/file-parsers/parser-formats.test.ts b/apps/sim/lib/file-parsers/parser-formats.test.ts new file mode 100644 index 00000000000..47a00356d13 --- /dev/null +++ b/apps/sim/lib/file-parsers/parser-formats.test.ts @@ -0,0 +1,296 @@ +/** + * @vitest-environment node + * + * Pins the `degraded` metadata contract to the parsers' real behaviour, using + * genuine OOXML archives rather than mocks. `DocParser` and `PptxParser` never + * throw by design — on a legacy OLE binary or a deck with no text they return a + * placeholder sentence or scraped ZIP internals. Automated callers rely on + * `degraded` to tell that apart from a real extraction, so if a parser stops + * setting the flag these tests are what catches it. + */ +import JSZip from 'jszip' +import { describe, expect, it } from 'vitest' +import * as XLSX from 'xlsx' +import { parseBuffer } from '@/lib/file-parsers' +import { DocParser } from '@/lib/file-parsers/doc-parser' +import { DocxParser } from '@/lib/file-parsers/docx-parser' +import { OpenDocumentParser } from '@/lib/file-parsers/opendocument-parser' +import { PptxParser } from '@/lib/file-parsers/pptx-parser' +import { XlsxParser } from '@/lib/file-parsers/xlsx-parser' + +const OOXML_CONTENT_TYPES_RELS = + '' + +function buildPptx(slideBodyXml: string, macroEnabled = false): Promise { + const mainType = macroEnabled + ? 'application/vnd.ms-powerpoint.presentation.macroEnabled.main+xml' + : 'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml' + const zip = new JSZip() + zip.file( + '[Content_Types].xml', + `${OOXML_CONTENT_TYPES_RELS}` + ) + zip.file( + '_rels/.rels', + `` + ) + zip.file( + 'ppt/presentation.xml', + `` + ) + zip.file( + 'ppt/_rels/presentation.xml.rels', + `` + ) + zip.file( + 'ppt/slides/slide1.xml', + `${slideBodyXml}` + ) + return zip.generateAsync({ type: 'nodebuffer' }) as Promise +} + +function buildDocx(bodyXml: string, macroEnabled = false): Promise { + const mainType = macroEnabled + ? 'application/vnd.ms-word.document.macroEnabled.main+xml' + : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml' + const zip = new JSZip() + zip.file( + '[Content_Types].xml', + `${OOXML_CONTENT_TYPES_RELS}` + ) + zip.file( + '_rels/.rels', + `` + ) + zip.file( + 'word/document.xml', + `${bodyXml}` + ) + return zip.generateAsync({ type: 'nodebuffer' }) as Promise +} + +/** OLE2 compound-file magic — how a genuine legacy .doc/.ppt/.xls begins. */ +function buildLegacyOleBinary(): Buffer { + return Buffer.concat([ + Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), + Buffer.alloc(2048, 0), + ]) +} + +/** + * End-to-end through the public API, so a wrong extension→parser mapping is caught + * (the registry test only proves *some* parser was found for each extension). + */ +describe('parseBuffer routes each extension to a parser that handles it', () => { + it('extracts a docm through the docx parser', async () => { + const result = await parseBuffer( + await buildDocx('Routed docm text', true), + 'docm' + ) + + expect(result.content).toContain('Routed docm text') + expect(result.metadata?.degraded).toBeFalsy() + }) + + it('extracts a pptm through the pptx parser', async () => { + const result = await parseBuffer( + await buildPptx( + 'Routed pptm text', + true + ), + 'pptm' + ) + + expect(result.content).toContain('Routed pptm text') + expect(result.metadata?.degraded).toBeFalsy() + }) + + it.each(['xlsx', 'xlsm', 'xlsb', 'ods'] as const)( + 'extracts a %s workbook through the spreadsheet parser', + async (bookType) => { + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([['Terminal'], ['BBG']]), 'Users') + const buffer = XLSX.write(wb, { type: 'buffer', bookType }) as Buffer + + const result = await parseBuffer(buffer, bookType) + + expect(result.content).toContain('BBG') + } + ) +}) + +describe('PptxParser degraded reporting', () => { + it('extracts slide text from a real pptx without flagging it degraded', async () => { + const buffer = await buildPptx( + 'Quarterly Market Data Review' + ) + + const result = await new PptxParser().parseBuffer(buffer) + + expect(result.content).toContain('Quarterly Market Data Review') + expect(result.metadata?.degraded).toBeFalsy() + }) + + /** + * A deck of images has no text for officeparser to return, and the fallback + * then scrapes the archive — the observed output begins `[Content_Types].xml`. + * Indexing that would put ZIP internals into the vector store. + */ + it('flags a deck with no extractable text as degraded', async () => { + const buffer = await buildPptx('') + + const result = await new PptxParser().parseBuffer(buffer) + + expect(result.metadata?.degraded).toBe(true) + }) + + it('flags a legacy OLE .ppt binary as degraded', async () => { + const result = await new PptxParser().parseBuffer(buildLegacyOleBinary()) + + expect(result.metadata?.degraded).toBe(true) + expect(result.content).toContain('Unable to extract text') + }) +}) + +describe('DocParser degraded reporting', () => { + it('flags a legacy OLE .doc binary as degraded', async () => { + const result = await new DocParser().parseBuffer(buildLegacyOleBinary()) + + expect(result.metadata?.degraded).toBe(true) + expect(result.content).toContain('Unable to extract text') + }) + + /** + * A real text file misnamed `.doc` is a genuine extraction, not a degraded one — + * the content is the file's actual text, so it stays indexable. + */ + it('does not flag a plain-text file misnamed .doc as degraded', async () => { + const result = await new DocParser().parseBuffer( + Buffer.from('Vendor list\nBloomberg\nRefinitiv\n') + ) + + expect(result.content).toContain('Bloomberg') + expect(result.metadata?.degraded).toBeFalsy() + }) +}) + +describe('DocxParser', () => { + it('extracts body text from a real docx without flagging it degraded', async () => { + const buffer = await buildDocx('Market Data SOP body text') + + const result = await new DocxParser().parseBuffer(buffer) + + expect(result.content).toContain('Market Data SOP body text') + expect(result.metadata?.degraded).toBeFalsy() + }) + + /** + * A macro-enabled `.docm` is the same WordprocessingML package with a different + * main-part content type. mammoth reads `word/document.xml` without consulting + * the content type, so it extracts identically — this pins that assumption. + */ + it('extracts a macro-enabled docm package', async () => { + const buffer = await buildDocx('Macro-enabled body', true) + + const result = await new DocxParser().parseBuffer(buffer) + + expect(result.content).toContain('Macro-enabled body') + expect(result.metadata?.degraded).toBeFalsy() + }) +}) + +describe('PptxParser macro-enabled package', () => { + it('extracts slide text from a pptm package', async () => { + const buffer = await buildPptx( + 'Macro deck slide', + true + ) + + const result = await new PptxParser().parseBuffer(buffer) + + expect(result.content).toContain('Macro deck slide') + expect(result.metadata?.degraded).toBeFalsy() + }) +}) + +describe('XlsxParser workbook containers', () => { + function workbook(bookType: XLSX.BookType): Buffer { + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet( + wb, + XLSX.utils.aoa_to_sheet([ + ['User', 'Terminal'], + ['jjean', 'BBG'], + ]), + 'Users' + ) + return XLSX.write(wb, { type: 'buffer', bookType }) as Buffer + } + + /** + * SheetJS reads all of these natively. `ods` is routed to this parser rather + * than `OpenDocumentParser` so a spreadsheet keeps its per-sheet structure. + */ + it.each(['xlsx', 'xlsm', 'xlsb', 'ods'] as const)('extracts cell text from %s', (bookType) => { + const result = new XlsxParser().parseBuffer(workbook(bookType)) + + return result.then((parsed) => { + expect(parsed.content).toContain('jjean') + expect(parsed.content).toContain('Terminal') + expect(parsed.metadata?.degraded).toBeFalsy() + }) + }) +}) + +describe('OpenDocumentParser', () => { + /** OpenDocument package: `mimetype` must be the first, STORED entry. */ + function buildOdf(mimetype: string, bodyXml: string): Promise { + const zip = new JSZip() + zip.file('mimetype', mimetype, { compression: 'STORE' }) + zip.file('META-INF/manifest.xml', '') + zip.file( + 'content.xml', + `${bodyXml}` + ) + return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) as Promise + } + + it('extracts paragraph text from an odt', async () => { + const buffer = await buildOdf( + 'application/vnd.oasis.opendocument.text', + 'OpenDocument paragraph' + ) + + const result = await new OpenDocumentParser().parseBuffer(buffer) + + expect(result.content).toContain('OpenDocument paragraph') + expect(result.metadata?.degraded).toBeFalsy() + }) + + it('extracts slide text from an odp', async () => { + const buffer = await buildOdf( + 'application/vnd.oasis.opendocument.presentation', + 'OpenDocument slide' + ) + + const result = await new OpenDocumentParser().parseBuffer(buffer) + + expect(result.content).toContain('OpenDocument slide') + }) + + /** + * No best-effort fallback here on purpose: the text lives in `content.xml`, so a + * failure means the archive is unreadable and scraping bytes would yield markup. + */ + it('throws rather than fabricating content for an unreadable archive', async () => { + await expect( + new OpenDocumentParser().parseBuffer(Buffer.from('not an archive')) + ).rejects.toThrow(/Failed to parse OpenDocument file|Failed to extract text/) + }) + + it('rejects an empty buffer', async () => { + await expect(new OpenDocumentParser().parseBuffer(Buffer.alloc(0))).rejects.toThrow( + 'Empty buffer provided' + ) + }) +}) diff --git a/apps/sim/lib/file-parsers/pptx-parser.ts b/apps/sim/lib/file-parsers/pptx-parser.ts index de670d2789d..c85cda70624 100644 --- a/apps/sim/lib/file-parsers/pptx-parser.ts +++ b/apps/sim/lib/file-parsers/pptx-parser.ts @@ -101,6 +101,7 @@ export class PptxParser implements FileParser { content, metadata: { extractionMethod: 'fallback', + degraded: true, characterCount: content.length, warning: 'Basic text extraction used', }, diff --git a/apps/sim/lib/file-parsers/registry.test.ts b/apps/sim/lib/file-parsers/registry.test.ts new file mode 100644 index 00000000000..6c7c0769e40 --- /dev/null +++ b/apps/sim/lib/file-parsers/registry.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + * + * Exercises the **real** parser registry — no mocks. `index.test.ts` stubs the + * `@/lib/file-parsers` module itself, so it validates its own fake routing table + * rather than the registry; nothing covered the real one. + * + * That gap is how a latent failure survived: the registry used to load each parser + * with `require()` inside a `try/catch` that only logged, so wherever those calls + * failed the registry came back **empty** and every format reported + * `Unsupported file type` — with an empty "Supported types are:" list as the only + * clue. Static imports plus this file make that state impossible to reach quietly. + */ +import { describe, expect, it } from 'vitest' +import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers' +import type { SupportedFileType } from '@/lib/file-parsers/types' + +/** + * Every member of the public union. Adding a type without registering a parser + * fails here instead of at runtime. + */ +const ALL_SUPPORTED_TYPES: SupportedFileType[] = [ + 'pdf', + 'csv', + 'doc', + 'docx', + 'docm', + 'dotx', + 'txt', + 'md', + 'xlsx', + 'xls', + 'xlsm', + 'xlsb', + 'xltx', + 'html', + 'htm', + 'pptx', + 'ppt', + 'pptm', + 'potx', + 'odt', + 'ods', + 'odp', +] + +describe('file parser registry', () => { + it('registers a parser for every SupportedFileType', () => { + for (const extension of ALL_SUPPORTED_TYPES) { + expect(isSupportedFileType(extension), `no parser registered for .${extension}`).toBe(true) + } + }) + + it('registers buffer parsing for every SupportedFileType', async () => { + for (const extension of ALL_SUPPORTED_TYPES) { + /** + * Fed a deliberately invalid document, so each parser is free to throw a + * parse error or return empty content — both mean routing found a parser. + * The only unacceptable outcome is a *routing* failure, which is what the + * two messages below report. Real extraction lives in `parser-formats.test.ts`. + */ + const outcome = await parseBuffer(Buffer.from('not a real document'), extension).catch( + (error: Error) => error + ) + + if (outcome instanceof Error) { + expect(outcome.message, `.${extension} routing`).not.toMatch( + /does not support buffer parsing|Unsupported file type/ + ) + } else { + expect(outcome, `.${extension} result`).toHaveProperty('content') + } + } + }) + + it('resolves extensions case-insensitively', () => { + expect(isSupportedFileType('DOCX')).toBe(true) + expect(isSupportedFileType('OdT')).toBe(true) + }) + + /** + * Formats with no bundled extractor must not claim support. `rtf` especially: + * `DocParser`'s plaintext branch would pass its control words through as prose. + */ + it('does not claim formats with no extractor', () => { + for (const extension of ['rtf', 'msg', 'eml', 'pages', 'key', 'one', 'vsdx', 'png']) { + expect(isSupportedFileType(extension), `unexpectedly claims .${extension}`).toBe(false) + } + }) + + it('names the registered types when rejecting an unknown extension', async () => { + await expect(parseBuffer(Buffer.from('x'), 'rtf')).rejects.toThrow(/Supported types are: .+/) + }) + + /** + * The extension is caller-supplied and reaches the registry as a lookup key. A + * plain object would resolve inherited keys, so `PARSERS['constructor']` handed + * back `Object` — truthy, with no parse methods — and routing fell through to + * "does not support buffer parsing" (or a `TypeError` in `parseFile`) instead of + * rejecting the extension. A `Map` has no prototype chain to walk. + */ + it.each(['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__'])( + 'treats the inherited key %s as an unsupported extension', + async (extension) => { + expect(isSupportedFileType(extension)).toBe(false) + await expect(parseBuffer(Buffer.from('x'), extension)).rejects.toThrow( + /Unsupported file type/ + ) + } + ) + + it('reports a non-string extension as unsupported rather than throwing', () => { + expect(isSupportedFileType(undefined as unknown as string)).toBe(false) + expect(isSupportedFileType(null as unknown as string)).toBe(false) + }) + + it('rejects an empty buffer before routing', async () => { + await expect(parseBuffer(Buffer.alloc(0), 'docx')).rejects.toThrow('Empty buffer provided') + }) + + it('rejects a missing extension', async () => { + await expect(parseBuffer(Buffer.from('x'), '')).rejects.toThrow('No file extension provided') + }) +}) diff --git a/apps/sim/lib/file-parsers/types.ts b/apps/sim/lib/file-parsers/types.ts index b8e945fe627..71f9d9764d0 100644 --- a/apps/sim/lib/file-parsers/types.ts +++ b/apps/sim/lib/file-parsers/types.ts @@ -3,6 +3,16 @@ export interface FileParseMetadata { pageCount?: number /** True when a parser limit stopped extraction before the input was exhausted. */ truncated?: boolean + /** + * True when no real extraction happened and `content` is best-effort scraped + * bytes or a placeholder message rather than the document's text. + * + * The legacy-format parsers (`doc`, `ppt`) deliberately never throw, so an + * interactive upload still shows the user something. An automated caller must + * not index that: it embeds ZIP internals or an English placeholder sentence as + * if it were document content. Such callers check this flag and skip the file. + */ + degraded?: boolean extractionMethod?: string warning?: string messages?: unknown[] @@ -31,11 +41,21 @@ export type SupportedFileType = | 'csv' | 'doc' | 'docx' + | 'docm' + | 'dotx' | 'txt' | 'md' | 'xlsx' | 'xls' + | 'xlsm' + | 'xlsb' + | 'xltx' | 'html' | 'htm' | 'pptx' | 'ppt' + | 'pptm' + | 'potx' + | 'odt' + | 'ods' + | 'odp' From ae2147645cf8c965729dbdd3262106966a363183 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 17 Aug 2026 15:55:04 -0700 Subject: [PATCH 04/26] fix(cli): resolve findings from a full command-surface audit (#6788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): resolve findings from a full command-surface audit Exercised all 147 commands against a live deployment. Fixes the defects that surfaced, plus the docs and generator drift they exposed. Transport - Stop following redirects. A bare domain that 301s to www silently converted POST to GET and dropped the body, so reads worked while every write failed with a misleading validation error and login returned 405. Both the client and the device flow now explain the redirect and name the endpoint to configure, rather than carrying credentials off-origin. - Report a non-JSON response as one instead of printing the HTML page. - Name the personal-API-key remedy on a workspace-key refusal, reading the machine-readable code the API actually sends. - Drop union-branch noise from validation errors that contradicted itself. - Show paging progress on stderr for multi-page fetches. Output - Clamp record values for table only. text is the format built for pipes, and it was truncating signed URLs and tool source mid-value. - Infer timestamp, duration, bytes and boolean formatting for API-owned keys so undeclared commands stop printing raw ISO and float ms. Skips user-defined table cells and leaves json/yaml on the raw payload. - Render a declared-but-absent field as an em dash; billing credits were vanishing silently. Paths, naming and validation - Percent-encode folder paths per segment and decode them for display, so a folder reads and types as the name shown in the app. - Reject a malformed endpoint where it is set and where it resolves, instead of crashing with a URL parse trace. - Request the detail level logs list's own columns need; its workflow column could never populate. - Rename three commands that described themselves wrongly and align two flags with their siblings. Old spellings still work: hidden, warned on stderr, and kept out of help and docs. - Verify whoami against the API, separating a bad key from an unreachable endpoint, and report the workspace by name. - Correct the --yes help text, which advertised skipping a prompt that does not exist. Docs - Teach the docs generator that a flag required by the runtime is required, and that hidden commands are not documented. * fix(cli): clear the paging progress line when a page fails Progress is written without a trailing newline so it can be overwritten in place, and both paging loops cleaned it up only on success. A page that threw part-way through left `fetched 1200…` on the line the error was then printed onto, so the two ran together. * fix(cli): name a working API root when an endpoint redirects The suggested endpoint was the redirect target's origin, which drops a path prefix. A self-hosted deployment reached at https://host/sim was told to set https://www.host — not an API root, so following the advice replaced one broken endpoint with another. Derive it by stripping the request's own path from the target instead, so a prefix survives, and say nothing about --set-endpoint when the target resolves to the endpoint already configured: a trailing-slash or path normalization redirect keeps the origin, and naming the value the caller already has explains nothing. The login poll shared both faults and now shares the helper. --- .../content/docs/en/cli/authentication.mdx | 13 +- apps/docs/content/docs/en/cli/commands.mdx | 14 +- apps/docs/content/docs/en/cli/credentials.mdx | 2 +- .../docs/content/docs/en/cli/custom-tools.mdx | 2 +- apps/docs/content/docs/en/cli/files.mdx | 24 +- apps/docs/content/docs/en/cli/knowledge.mdx | 30 +- apps/docs/content/docs/en/cli/logs.mdx | 4 +- apps/docs/content/docs/en/cli/mcp-servers.mdx | 2 +- apps/docs/content/docs/en/cli/reference.mdx | 204 +++++---- apps/docs/content/docs/en/cli/scripting.mdx | 15 + apps/docs/content/docs/en/cli/secrets.mdx | 2 +- apps/docs/content/docs/en/cli/skills.mdx | 2 +- apps/docs/content/docs/en/cli/tables.mdx | 88 ++-- .../content/docs/en/cli/troubleshooting.mdx | 9 +- apps/docs/content/docs/en/cli/workflows.mdx | 26 +- packages/sim-cli/README.md | 13 +- packages/sim-cli/src/auth/device-flow.test.ts | 22 + packages/sim-cli/src/auth/device-flow.ts | 46 +- packages/sim-cli/src/commands/auth.test.ts | 160 ++++++- packages/sim-cli/src/commands/auth.ts | 158 ++++++- .../sim-cli/src/commands/configure.test.ts | 52 +++ packages/sim-cli/src/commands/configure.ts | 5 +- .../commands/protocol/files-upload.test.ts | 25 + .../src/commands/protocol/files-upload.ts | 9 +- .../protocol/resource-directory.test.ts | 70 +++ .../commands/protocol/resource-directory.ts | 22 +- .../commands/protocol/tables-import.test.ts | 21 + .../src/commands/protocol/tables-import.ts | 6 +- packages/sim-cli/src/config/profile.test.ts | 30 ++ packages/sim-cli/src/config/profile.ts | 34 +- .../sim-cli/src/contract/commands.test.ts | 178 ++++++++ packages/sim-cli/src/contract/commands.ts | 98 +++- packages/sim-cli/src/contract/types.ts | 61 ++- packages/sim-cli/src/http/client.test.ts | 428 +++++++++++++++++- packages/sim-cli/src/http/client.ts | 319 +++++++++++-- packages/sim-cli/src/output/render.test.ts | 16 + packages/sim-cli/src/output/render.ts | 36 +- packages/sim-cli/src/runtime/build.test.ts | 159 ++++++- packages/sim-cli/src/runtime/build.ts | 64 ++- packages/sim-cli/src/runtime/execute.ts | 73 ++- packages/sim-cli/src/runtime/options.test.ts | 38 ++ packages/sim-cli/src/runtime/options.ts | 20 +- packages/sim-cli/src/runtime/renamed.ts | 41 ++ packages/sim-cli/src/runtime/request.test.ts | 91 +++- packages/sim-cli/src/runtime/request.ts | 73 ++- packages/sim-cli/src/runtime/result.test.ts | 222 +++++++++ packages/sim-cli/src/runtime/result.ts | 164 ++++++- scripts/generate-cli-docs.ts | 56 ++- 48 files changed, 2877 insertions(+), 370 deletions(-) create mode 100644 packages/sim-cli/src/commands/configure.test.ts create mode 100644 packages/sim-cli/src/contract/commands.test.ts create mode 100644 packages/sim-cli/src/runtime/options.test.ts create mode 100644 packages/sim-cli/src/runtime/renamed.ts create mode 100644 packages/sim-cli/src/runtime/result.test.ts diff --git a/apps/docs/content/docs/en/cli/authentication.mdx b/apps/docs/content/docs/en/cli/authentication.mdx index b913ddf7550..72ad91065b0 100644 --- a/apps/docs/content/docs/en/cli/authentication.mdx +++ b/apps/docs/content/docs/en/cli/authentication.mdx @@ -56,11 +56,18 @@ re-logging into an existing profile preselects the one already configured. ## Checking who you are ```bash -sim whoami +sim whoami # resolved settings, plus a live check that they work +sim whoami --no-verify # resolved settings only, no request ``` -Prints the resolved endpoint, workspace, output format, and account, and which -source each value came from. +Prints the resolved endpoint, workspace, and output format, and which source each +value came from, then reads the configured workspace to prove the key is accepted +and can reach it. + +It exits `0` when the check passes, `1` when the credentials are wrong, and `2` +when the check could not be made at all — no workspace to check against, or an +endpoint that did not answer. The split matters in CI: only `1` is fixed by +logging in again. ## Signing out diff --git a/apps/docs/content/docs/en/cli/commands.mdx b/apps/docs/content/docs/en/cli/commands.mdx index dab4a4295f2..cbe18f15bbf 100644 --- a/apps/docs/content/docs/en/cli/commands.mdx +++ b/apps/docs/content/docs/en/cli/commands.mdx @@ -78,12 +78,22 @@ sim logout [options] -## Show the resolved profile and where each setting came from +## Show the resolved profile, where each setting came from, and whether it works ```bash -sim whoami +sim whoami [options] ``` +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--no-verify` | No | Skip the API check and only print the resolved settings. | + + + ## List the profiles defined in the config and credentials files ```bash diff --git a/apps/docs/content/docs/en/cli/credentials.mdx b/apps/docs/content/docs/en/cli/credentials.mdx index 534d284bf88..ff0e850e2c3 100644 --- a/apps/docs/content/docs/en/cli/credentials.mdx +++ b/apps/docs/content/docs/en/cli/credentials.mdx @@ -31,7 +31,7 @@ sim credentials delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | diff --git a/apps/docs/content/docs/en/cli/custom-tools.mdx b/apps/docs/content/docs/en/cli/custom-tools.mdx index 4830c239cc6..97e61c50269 100644 --- a/apps/docs/content/docs/en/cli/custom-tools.mdx +++ b/apps/docs/content/docs/en/cli/custom-tools.mdx @@ -49,7 +49,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index 43e106fac57..77cf0ca74c2 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -22,7 +22,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -40,7 +40,7 @@ sim files create [options] | --- | --- | --- | | `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | | `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger. | | `--encoding ` | No | Encoding of the content field. Accepted values: `utf-8`, `base64`. | @@ -58,7 +58,7 @@ sim files folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -74,7 +74,7 @@ sim files folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -85,7 +85,7 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -124,8 +124,8 @@ Also available as `sim files folders mv`. | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | -| `destination` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | @@ -151,7 +151,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -238,7 +238,7 @@ sim files list [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | @@ -292,10 +292,10 @@ sim files rename [options] -## Restore file +## Restore an archived file ```bash -sim files restore create +sim files restore ``` **Arguments** @@ -357,7 +357,7 @@ sim files upload [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Destination folder path (defaults to /). | +| `--folder ` | No | Folder path as shown in the app; defaults to the root folder. | | `--name ` | No | Store it under a different name. | diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index 2333dc80edb..90f17671e7b 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -61,7 +61,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -116,7 +116,7 @@ sim knowledge documents list [options] ## Update document ```bash -sim knowledge documents update [options] +sim knowledge documents update [options] ``` **Arguments** @@ -125,7 +125,7 @@ sim knowledge documents update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | | `documentId` | Yes | Unique knowledge document identifier. | @@ -209,7 +209,7 @@ sim knowledge create [options] | `--name ` | Yes | Human-readable knowledge base name. | | `--description ` | No | Optional knowledge base description. | | `--chunking-config ` | No | Chunking configuration; defaults are applied when omitted. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -225,7 +225,7 @@ sim knowledge folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -241,7 +241,7 @@ sim knowledge folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -252,7 +252,7 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -291,8 +291,8 @@ Also available as `sim knowledge folders mv`. | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | -| `destination` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | @@ -318,7 +318,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -350,7 +350,7 @@ sim knowledge list [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -361,7 +361,7 @@ sim knowledge list [options] ## List tags ```bash -sim knowledge tags list +sim knowledge tags list ``` **Arguments** @@ -370,7 +370,7 @@ sim knowledge tags list | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -423,7 +423,7 @@ sim knowledge update [options] | `--name ` | No | New knowledge base name. | | `--description ` | No | New knowledge base description. | | `--chunking-config ` | No | New document chunking configuration. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -440,7 +440,7 @@ sim knowledge mv | Argument | Required | Description | | --- | --- | --- | | `id` | Yes | Unique knowledge base identifier. | -| `folder` | Yes | Folder path; the leading / is optional | +| `folder` | Yes | Folder path as shown in the app; the leading / is optional | diff --git a/apps/docs/content/docs/en/cli/logs.mdx b/apps/docs/content/docs/en/cli/logs.mdx index 1418a0226b0..d2c3420c550 100644 --- a/apps/docs/content/docs/en/cli/logs.mdx +++ b/apps/docs/content/docs/en/cli/logs.mdx @@ -57,12 +57,12 @@ sim logs list [options] | `--min-cost ` | No | Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run. | | `--max-cost ` | No | Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run. | | `--model ` | No | AI model used during execution. | -| `--details ` | No | Response detail level. Accepted values: `basic`, `full`. | +| `--details ` | No | Response detail level; full is requested by default to name each run’s workflow. Accepted values: `basic`, `full`. | | `--include-trace-spans` | No | Include trace spans in JSON or YAML output (implies full detail). | | `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--order ` | No | Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects. Accepted values: `asc`, `desc`. | | `--run-id ` | No | Exact run identifier to match. | -| `--folder ` | No | Folder path; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | diff --git a/apps/docs/content/docs/en/cli/mcp-servers.mdx b/apps/docs/content/docs/en/cli/mcp-servers.mdx index ea6c97506f0..46afdcad279 100644 --- a/apps/docs/content/docs/en/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/mcp-servers.mdx @@ -58,7 +58,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index cd24d1722fb..51250137abb 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -64,12 +64,22 @@ sim logout [options] ## sim whoami -Show the resolved profile and where each setting came from +Show the resolved profile, where each setting came from, and whether it works ```bash -sim whoami +sim whoami [options] ``` +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--no-verify` | No | Skip the API check and only print the resolved settings. | + + + ## sim profiles List the profiles defined in the config and credentials files @@ -232,7 +242,7 @@ sim credentials delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -402,7 +412,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -494,7 +504,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -514,7 +524,7 @@ sim files create [options] | --- | --- | --- | | `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | | `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger. | | `--encoding ` | No | Encoding of the content field. Accepted values: `utf-8`, `base64`. | @@ -534,7 +544,7 @@ sim files folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -552,7 +562,7 @@ sim files folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -563,7 +573,7 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -606,8 +616,8 @@ Also available as `sim files folders mv`. | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | -| `destination` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | @@ -635,7 +645,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -730,7 +740,7 @@ sim files list [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | @@ -788,12 +798,12 @@ sim files rename [options] -### sim files restore create +### sim files restore -Restore File +Restore an archived file ```bash -sim files restore create +sim files restore ``` **Arguments** @@ -859,7 +869,7 @@ sim files upload [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Destination folder path (defaults to /). | +| `--folder ` | No | Folder path as shown in the app; defaults to the root folder. | | `--name ` | No | Store it under a different name. | @@ -1000,7 +1010,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -1061,7 +1071,7 @@ sim knowledge documents list [options] Update Document ```bash -sim knowledge documents update [options] +sim knowledge documents update [options] ``` **Arguments** @@ -1070,7 +1080,7 @@ sim knowledge documents update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | | `documentId` | Yes | Unique knowledge document identifier. | @@ -1158,7 +1168,7 @@ sim knowledge create [options] | `--name ` | Yes | Human-readable knowledge base name. | | `--description ` | No | Optional knowledge base description. | | `--chunking-config ` | No | Chunking configuration; defaults are applied when omitted. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -1176,7 +1186,7 @@ sim knowledge folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -1194,7 +1204,7 @@ sim knowledge folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -1205,7 +1215,7 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -1248,8 +1258,8 @@ Also available as `sim knowledge folders mv`. | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | -| `destination` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | @@ -1277,7 +1287,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -1313,7 +1323,7 @@ sim knowledge list [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -1326,7 +1336,7 @@ sim knowledge list [options] List Tags ```bash -sim knowledge tags list +sim knowledge tags list ``` **Arguments** @@ -1335,7 +1345,7 @@ sim knowledge tags list | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -1392,7 +1402,7 @@ sim knowledge update [options] | `--name ` | No | New knowledge base name. | | `--description ` | No | New knowledge base description. | | `--chunking-config ` | No | New document chunking configuration. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -1411,7 +1421,7 @@ sim knowledge mv | Argument | Required | Description | | --- | --- | --- | | `id` | Yes | Unique knowledge base identifier. | -| `folder` | Yes | Folder path; the leading / is optional | +| `folder` | Yes | Folder path as shown in the app; the leading / is optional | @@ -1518,13 +1528,13 @@ sim logs list [options] | `--min-cost ` | No | Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run. | | `--max-cost ` | No | Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run. | | `--model ` | No | AI model used during execution. | -| `--details ` | No | Response detail level. Accepted values: `basic`, `full`. | +| `--details ` | No | Response detail level; full is requested by default to name each run’s workflow. Accepted values: `basic`, `full`. | | `--include-trace-spans` | No | Include trace spans in JSON or YAML output (implies full detail). | | `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--order ` | No | Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects. Accepted values: `asc`, `desc`. | | `--run-id ` | No | Exact run identifier to match. | -| `--folder ` | No | Folder path; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | @@ -1585,7 +1595,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -1725,7 +1735,7 @@ sim secrets delete [options] | Option | Required | Description | | --- | --- | --- | | `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -1828,7 +1838,7 @@ sim skills delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -1958,7 +1968,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -2080,7 +2090,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -2307,7 +2317,7 @@ sim tables create [options] | `--name ` | Yes | Identifier: letters, numbers, and underscores; cannot start with a number. | | `--description ` | No | Optional table description. | | `--schema ` | Yes | Table schema: {"columns":[{"name":"email","type":"string"}]} (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -2325,7 +2335,7 @@ sim tables folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -2343,7 +2353,7 @@ sim tables folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -2354,7 +2364,7 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -2397,8 +2407,8 @@ Also available as `sim tables folders mv`. | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | -| `destination` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | @@ -2456,7 +2466,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -2487,7 +2497,7 @@ sim tables rows batch-delete [options] | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -2515,7 +2525,7 @@ sim tables rows find [options] | Option | Required | Description | | --- | --- | --- | -| `--q ` | Yes | Value to find. | +| `--query ` | Yes | Value to find. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | @@ -2598,6 +2608,34 @@ sim tables rows query [options] +### sim tables rows count + +Count rows matching a filter + +```bash +sim tables rows count [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | + + + ### sim tables rows enrich Run one row’s enrichment group @@ -2645,7 +2683,7 @@ sim tables rows batch-update [options] | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -2732,7 +2770,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -2830,7 +2868,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -2866,7 +2904,7 @@ sim tables list [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -2874,34 +2912,6 @@ sim tables list [options] -### sim tables count create - -Count Rows - -```bash -sim tables count create [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--predicate ` | No | Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids. (JSON, or @path / @- to read a file or stdin). | - - - ### sim tables update Update Table @@ -2928,7 +2938,7 @@ sim tables update [options] | --- | --- | --- | | `--name ` | No | Identifier: letters, numbers, and underscores; cannot start with a number. | | `--description ` | No | Replacement table description, or null to clear it. | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -2947,7 +2957,7 @@ sim tables mv | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | -| `folder` | Yes | Folder path; the leading / is optional | +| `folder` | Yes | Folder path as shown in the app; the leading / is optional | @@ -3007,7 +3017,7 @@ sim tables import [path] [options] | `--name ` | No | Identifier for the new table: letters, numbers, and underscores; defaults to the sanitized file name. | | `--table-id ` | No | Import into this existing table instead of creating one. | | `--mode ` | No | How to write into --table-id (default: append). Accepted values: `append`, `replace`. | -| `--folder ` | No | Folder path for the new table. | +| `--folder ` | No | Folder path for the new table, as shown in the app. | | `--file-id ` | No | Import a file already in the workspace instead of a local path. | | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | @@ -3195,7 +3205,7 @@ sim workflows create [options] | --- | --- | --- | | `--name ` | Yes | Workflow name. | | `--description ` | No | Optional workflow description. | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -3213,7 +3223,7 @@ sim workflows folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -3231,7 +3241,7 @@ sim workflows folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -3242,7 +3252,7 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -3285,8 +3295,8 @@ Also available as `sim workflows folders mv`. | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | -| `destination` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | @@ -3314,7 +3324,7 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -3417,12 +3427,12 @@ sim workflows get -### sim workflows deployment list +### sim workflows deployment status -Get Workflow Deployment +Show a workflow’s current deployment ```bash -sim workflows deployment list +sim workflows deployment status ``` **Arguments** @@ -3497,7 +3507,7 @@ sim workflows import [options] | Option | Required | Description | | --- | --- | --- | | `--workflow ` | Yes | Workflow export object, bare workflow state, or JSON string containing either form. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--name ` | No | Override for the imported workflow name. | | `--description ` | No | Override for the imported workflow description. | @@ -3517,7 +3527,7 @@ sim workflows list [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | @@ -3599,7 +3609,7 @@ sim workflows update [options] | --- | --- | --- | | `--name ` | No | Replacement workflow name. | | `--description ` | No | Replacement workflow description; null clears it. | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -3618,7 +3628,7 @@ sim workflows mv | Argument | Required | Description | | --- | --- | --- | | `id` | Yes | Unique workflow identifier. | -| `folder` | Yes | Folder path; the leading / is optional | +| `folder` | Yes | Folder path as shown in the app; the leading / is optional | diff --git a/apps/docs/content/docs/en/cli/scripting.mdx b/apps/docs/content/docs/en/cli/scripting.mdx index 44627544323..32be39572b9 100644 --- a/apps/docs/content/docs/en/cli/scripting.mdx +++ b/apps/docs/content/docs/en/cli/scripting.mdx @@ -86,6 +86,7 @@ filter matching more rows than that silently affects only the first 100. Pass | --- | --- | | `0` | Success | | `1` | Anything else — API error, bad configuration, invalid arguments, or a missing `--yes` | +| `2` | `sim whoami` only: the check could not be made at all | Errors print one line to stderr, prefixed `Error:`, plus the API's error code and validation details when it supplies them. Failures are safe to branch on: @@ -100,6 +101,20 @@ fi An unexpected error prints a stack trace — that is a bug in the CLI, so please [open an issue](https://github.com/simstudioai/sim/issues). +`sim whoami` splits its failure in two because the fixes differ: `1` means the +credentials are wrong and a fresh `sim login` is the answer, while `2` means the +CLI never got a verdict — no workspace to check against, or an endpoint that did +not answer — and logging in again would not help. + +```bash +sim whoami > /dev/null +case $? in + 0) ;; # ready + 1) echo "run: sim login" >&2; exit 1 ;; + 2) echo "endpoint unreachable, retrying later" >&2; exit 75 ;; +esac +``` + ## Selecting workflow output `--select-output` takes `blockName.field` selectors. Fields that a run did not diff --git a/apps/docs/content/docs/en/cli/secrets.mdx b/apps/docs/content/docs/en/cli/secrets.mdx index 231a493adcd..b13dfb1751f 100644 --- a/apps/docs/content/docs/en/cli/secrets.mdx +++ b/apps/docs/content/docs/en/cli/secrets.mdx @@ -32,7 +32,7 @@ sim secrets delete [options] | Option | Required | Description | | --- | --- | --- | | `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index e5560dd276f..a965506ec1c 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -49,7 +49,7 @@ sim skills delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | diff --git a/apps/docs/content/docs/en/cli/tables.mdx b/apps/docs/content/docs/en/cli/tables.mdx index 281a03b44df..9f477ab9501 100644 --- a/apps/docs/content/docs/en/cli/tables.mdx +++ b/apps/docs/content/docs/en/cli/tables.mdx @@ -58,7 +58,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -172,7 +172,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -379,7 +379,7 @@ sim tables create [options] | `--name ` | Yes | Identifier: letters, numbers, and underscores; cannot start with a number. | | `--description ` | No | Optional table description. | | `--schema ` | Yes | Table schema: {"columns":[{"name":"email","type":"string"}]} (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -395,7 +395,7 @@ sim tables folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -411,7 +411,7 @@ sim tables folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -422,7 +422,7 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -461,8 +461,8 @@ Also available as `sim tables folders mv`. | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | -| `destination` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | @@ -516,7 +516,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -545,7 +545,7 @@ sim tables rows batch-delete [options] | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -571,7 +571,7 @@ sim tables rows find [options] | Option | Required | Description | | --- | --- | --- | -| `--q ` | Yes | Value to find. | +| `--query ` | Yes | Value to find. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | @@ -648,6 +648,32 @@ sim tables rows query [options] +## Count rows matching a filter + +```bash +sim tables rows count [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | + + + ## Run one row’s enrichment group ```bash @@ -691,7 +717,7 @@ sim tables rows batch-update [options] | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -772,7 +798,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -862,7 +888,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -894,7 +920,7 @@ sim tables list [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -902,32 +928,6 @@ sim tables list [options] -## Count rows - -```bash -sim tables count create [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--predicate ` | No | Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids. (JSON, or @path / @- to read a file or stdin). | - - - ## Update table ```bash @@ -952,7 +952,7 @@ sim tables update [options] | --- | --- | --- | | `--name ` | No | Identifier: letters, numbers, and underscores; cannot start with a number. | | `--description ` | No | Replacement table description, or null to clear it. | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -969,7 +969,7 @@ sim tables mv | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | -| `folder` | Yes | Folder path; the leading / is optional | +| `folder` | Yes | Folder path as shown in the app; the leading / is optional | @@ -1025,7 +1025,7 @@ sim tables import [path] [options] | `--name ` | No | Identifier for the new table: letters, numbers, and underscores; defaults to the sanitized file name. | | `--table-id ` | No | Import into this existing table instead of creating one. | | `--mode ` | No | How to write into --table-id (default: append). Accepted values: `append`, `replace`. | -| `--folder ` | No | Folder path for the new table. | +| `--folder ` | No | Folder path for the new table, as shown in the app. | | `--file-id ` | No | Import a file already in the workspace instead of a local path. | | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | diff --git a/apps/docs/content/docs/en/cli/troubleshooting.mdx b/apps/docs/content/docs/en/cli/troubleshooting.mdx index 7a8cd1441a5..4d3e2c59c73 100644 --- a/apps/docs/content/docs/en/cli/troubleshooting.mdx +++ b/apps/docs/content/docs/en/cli/troubleshooting.mdx @@ -3,11 +3,14 @@ title: Troubleshooting description: The failures whose cause is not obvious from the error message --- -Errors print one line to stderr, prefixed `Error:`, and exit `1`. Most say what -to do next; the cases below are the ones that do not. +Errors print one line to stderr, prefixed `Error:`, and exit `1` — except +`sim whoami`, which exits `2` when it could not reach the API to check at all. +Most say what to do next; the cases below are the ones that do not. Start with `sim whoami`. It prints the resolved endpoint, workspace, and output -format, **and where each came from** — which explains most surprises on its own. +format, **and where each came from** — which explains most surprises on its own — +then checks the resolved key against the API. Add `--no-verify` to skip the check +and stay offline. ## A command targets the wrong workspace or deployment diff --git a/apps/docs/content/docs/en/cli/workflows.mdx b/apps/docs/content/docs/en/cli/workflows.mdx index 23b387b8202..19ffd718bde 100644 --- a/apps/docs/content/docs/en/cli/workflows.mdx +++ b/apps/docs/content/docs/en/cli/workflows.mdx @@ -131,7 +131,7 @@ sim workflows create [options] | --- | --- | --- | | `--name ` | Yes | Workflow name. | | `--description ` | No | Optional workflow description. | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -147,7 +147,7 @@ sim workflows folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -163,7 +163,7 @@ sim workflows folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -174,7 +174,7 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -213,8 +213,8 @@ Also available as `sim workflows folders mv`. | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path; the leading / is optional | -| `destination` | Yes | Folder path; the leading / is optional | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | @@ -240,7 +240,7 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | No | Skip the confirmation. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -335,10 +335,10 @@ sim workflows get -## Get workflow deployment +## Show a workflow’s current deployment ```bash -sim workflows deployment list +sim workflows deployment status ``` **Arguments** @@ -407,7 +407,7 @@ sim workflows import [options] | Option | Required | Description | | --- | --- | --- | | `--workflow ` | Yes | Workflow export object, bare workflow state, or JSON string containing either form. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--name ` | No | Override for the imported workflow name. | | `--description ` | No | Override for the imported workflow description. | @@ -425,7 +425,7 @@ sim workflows list [options] | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | @@ -501,7 +501,7 @@ sim workflows update [options] | --- | --- | --- | | `--name ` | No | Replacement workflow name. | | `--description ` | No | Replacement workflow description; null clears it. | -| `--folder ` | No | Folder path; the leading / is optional. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -518,7 +518,7 @@ sim workflows mv | Argument | Required | Description | | --- | --- | --- | | `id` | Yes | Unique workflow identifier. | -| `folder` | Yes | Folder path; the leading / is optional | +| `folder` | Yes | Folder path as shown in the app; the leading / is optional | diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 1b7682fdb15..dbbbbd1c9d8 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -46,7 +46,7 @@ The section-naming asymmetry — `[profile dev]` in config, `[dev]` in credentia sim configure --set-endpoint http://localhost:3000 --profile dev sim configure --set-workspace ws_local --profile dev sim profiles # list them; * marks the active one -sim whoami # resolved values, and where each came from +sim whoami # resolved values, where each came from, and whether they work ``` ## Where settings come from @@ -63,7 +63,16 @@ Each setting resolves independently, first match wins: Formats are listed under [Output formats](#output-formats). `sim whoami` prints the winning source per setting, which is usually the fastest -way to explain a surprising result. +way to explain a surprising result. It then reads the configured workspace to +prove the settings actually work; `--no-verify` skips that and stays offline. + +Its exit status is the answer, so CI can branch on it: + +| Code | Meaning | +| --- | --- | +| `0` | The key works and reached the configured workspace | +| `1` | The credentials are wrong — no key stored, or the API refused it | +| `2` | The check could not be made — nothing to check against, or the endpoint did not answer | For CI, skip `sim login` entirely and set `SIM_API_KEY` and `SIM_WORKSPACE` — nothing needs to touch the filesystem. `SIM_CONFIG_DIR` relocates both files if diff --git a/packages/sim-cli/src/auth/device-flow.test.ts b/packages/sim-cli/src/auth/device-flow.test.ts index 4b2747c53ce..6fa31cc3d80 100644 --- a/packages/sim-cli/src/auth/device-flow.test.ts +++ b/packages/sim-cli/src/auth/device-flow.test.ts @@ -96,6 +96,28 @@ describe('pollForKey', () => { it('gives up on a 403', async () => { await expect(poll([() => reply(403, { error: 'Forbidden' })])).rejects.toThrow('Forbidden') }) + + it('asks fetch not to follow a redirect', async () => { + // Following one rewrites this POST into a bodyless GET — which the route + // answers 405, a status nothing in the login chose — and hands `pollSecret` + // to whatever origin `Location` names. + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(reply(200, COMPLETE)) + await pollForKey(ENDPOINT, createAuthRequest()) + + expect(fetchMock.mock.calls[0][1]).toMatchObject({ redirect: 'manual' }) + }) + + it('explains a redirected endpoint instead of failing on the method it became', async () => { + await expect( + poll([ + () => + new Response(null, { + status: 301, + headers: { location: 'https://www.sim.test/api/cli/auth/poll' }, + }), + ]) + ).rejects.toThrow(/redirected the login poll to https:\/\/www\.sim\.test/) + }) }) describe('createAuthRequest', () => { diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts index 198f3610c30..84dfaf3c2ba 100644 --- a/packages/sim-cli/src/auth/device-flow.ts +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes, randomInt } from 'node:crypto' import { sleep } from '../helpers' -import { SimApiError } from '../http/client' +import { REDIRECT_STATUSES, redirectEndpoint, SimApiError } from '../http/client' /** * The terminal half of the CLI key handoff. @@ -105,6 +105,45 @@ interface PollResponse { workspaceBound?: boolean } +/** The route the login poll targets; also the suffix a redirect target is measured against. */ +const POLL_PATH = '/api/cli/auth/poll' + +/** + * Explains a redirected poll rather than following it. + * + * The same policy `SimClient` applies, for the same two reasons and one more: + * a 301/302/303 rewrites this POST into a bodyless GET, which the route answers + * `405` — the login then fails naming a method nobody chose — and a redirect + * that IS followed hands `pollSecret`, the one redeemable value in the handoff, + * to whatever origin `Location` names. + */ +function toRedirectError(endpoint: string, response: Response): SimApiError { + const location = response.headers.get('location')?.trim() + let target: URL | null = null + if (location) { + try { + target = new URL(location, endpoint) + } catch { + target = null + } + } + + if (!target) { + return new SimApiError( + `${endpoint} answered the login poll with HTTP ${response.status} and no usable redirect target. Check the endpoint.`, + response.status + ) + } + const refusal = `${endpoint} redirected the login poll to ${target.href}. The CLI does not follow redirects, because a redirect drops the request body and would carry the login secret to another origin.` + const suggested = redirectEndpoint(endpoint, POLL_PATH, target) + return new SimApiError( + suggested + ? `${refusal} Re-run with --endpoint ${suggested}, or run: sim configure --set-endpoint ${suggested}` + : refusal, + response.status + ) +} + /** * Polls until the user approves in the browser. * @@ -126,17 +165,20 @@ export async function pollForKey( let response: Response | null = null try { - response = await fetch(new URL('/api/cli/auth/poll', endpoint), { + response = await fetch(new URL(POLL_PATH, endpoint), { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ request: auth.request, verifier: auth.pollSecret }), signal, + redirect: 'manual', }) } catch { response = null } if (response) { + if (REDIRECT_STATUSES.has(response.status)) throw toRedirectError(endpoint, response) + const raw = await response.text() if (!response.ok) { diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index a75fe6174ee..a7028c139f4 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ createAuthRequest: vi.fn(() => ({ pairing: 'ABCD', verifier: 'verifier' })), createInterface: vi.fn(), listProfiles: vi.fn<() => string[]>(() => []), + request: vi.fn(), readCredentialsProfile: vi.fn<() => Record>(() => ({})), pollForKey: vi.fn(async () => ({ apiKey: 'sim-key', @@ -44,8 +45,12 @@ vi.mock('../config/index', () => ({ writeConfigProfile: mocks.writeConfigProfile, writeCredentialsProfile: mocks.writeCredentialsProfile, })) -vi.mock('../context', () => ({ profileFrom: mocks.profileFrom })) +vi.mock('../context', () => ({ + profileFrom: mocks.profileFrom, + clientFrom: () => ({ client: { request: mocks.request }, profile: mocks.profileFrom() }), +})) +import { SimApiError } from '../http/client' import { loginCommand, profilesCommand, whoamiCommand } from './auth' const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') @@ -204,17 +209,14 @@ describe('profiles command', () => { }) describe('whoami command', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) + const originalExitCode = process.exitCode - it('reports authentication without exposing any part of the API key', async () => { - mocks.profileFrom.mockReturnValue({ + function configured(overrides: Partial> = {}) { + return { name: 'default', endpoint: 'https://sim.ai', - apiKey: 'sim_super_secret_value', - workspaceId: 'ws_1', + apiKey: 'sim_super_secret_value' as string | null, + workspaceId: 'ws_1' as string | null, output: 'text', sources: { endpoint: 'default', @@ -222,8 +224,25 @@ describe('whoami command', () => { workspaceId: 'config', output: 'flag', }, + ...overrides, + } + } + + beforeEach(() => { + vi.clearAllMocks() + process.exitCode = undefined + mocks.profileFrom.mockReturnValue(configured()) + mocks.request.mockResolvedValue({ + data: { id: 'ws_1', name: "Waleed Latif's Workspace", memberCount: 3 }, }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + process.exitCode = originalExitCode + }) + it('reports authentication without exposing any part of the API key', async () => { await whoami() const output = vi.mocked(console.log).mock.calls.flat().join('\n') @@ -233,19 +252,7 @@ describe('whoami command', () => { }) it('uses non-secret-shaped authentication metadata in machine output', async () => { - mocks.profileFrom.mockReturnValue({ - name: 'default', - endpoint: 'https://sim.ai', - apiKey: 'sim_super_secret_value', - workspaceId: 'ws_1', - output: 'json', - sources: { - endpoint: 'default', - apiKey: 'credentials', - workspaceId: 'config', - output: 'flag', - }, - }) + mocks.profileFrom.mockReturnValue(configured({ output: 'json' })) await whoami() @@ -253,8 +260,117 @@ describe('whoami command', () => { expect(JSON.parse(output)).toMatchObject({ authenticated: true, sources: { authentication: 'credentials' }, + verification: { + status: 'verified', + workspace: { id: 'ws_1', name: "Waleed Latif's Workspace", memberCount: 3 }, + }, }) expect(output).not.toContain('apiKey') expect(output).not.toContain('sim_super_secret_value') }) + + it('checks the key against the API and names the workspace it reached', async () => { + await whoami() + + expect(mocks.request).toHaveBeenCalledWith('/api/v2/workspaces/ws_1', { method: 'GET' }) + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain("Waleed Latif's Workspace") + expect(output).toContain('3 members') + expect(process.exitCode).toBeUndefined() + }) + + it('exits 1 when the API rejects the key, without hiding the resolved settings', async () => { + mocks.request.mockRejectedValue( + new SimApiError('Invalid API key — run: sim login --profile default', 401) + ) + + await whoami() + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('Endpoint\thttps://sim.ai') + expect(output).toContain('Invalid API key') + expect(process.exitCode).toBe(1) + }) + + it('exits 2 rather than blaming the key when the endpoint cannot be reached', async () => { + mocks.request.mockRejectedValue( + new SimApiError('Could not reach https://sim.ai: fetch failed', 0) + ) + + await whoami() + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('could not check — Could not reach https://sim.ai') + expect(process.exitCode).toBe(2) + }) + + it('exits 2 rather than blaming the key when the API itself is down', async () => { + // A 502 from a proxy mid-deploy said `✗ Bad Gateway` and exited 1, which + // tells a script to run `sim login` for something logging in cannot fix. + mocks.request.mockRejectedValue(new SimApiError('Bad Gateway', 502)) + + await whoami() + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('could not check — Bad Gateway') + expect(process.exitCode).toBe(2) + }) + + it('exits 2 when the endpoint answers something other than the API', async () => { + // A wrong endpoint that serves a landing page comes back as a 200 the JSON + // client could not parse; the key was never judged. + mocks.request.mockRejectedValue( + new SimApiError('https://sim.ai/api/v2/workspaces/ws_1 returned HTML, not JSON', 200) + ) + + await whoami() + + expect(process.exitCode).toBe(2) + }) + + it('exits 1 when the key cannot reach the configured workspace', async () => { + mocks.request.mockRejectedValue(new SimApiError('Workspace not found', 404)) + + await whoami() + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('Workspace not found') + expect(process.exitCode).toBe(1) + }) + + it('exits 2 when no workspace is configured, because the check reads one', async () => { + mocks.profileFrom.mockReturnValue( + configured({ workspaceId: null, sources: { ...configured().sources, workspaceId: 'unset' } }) + ) + + await whoami() + + expect(mocks.request).not.toHaveBeenCalled() + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('no workspace to check against') + expect(process.exitCode).toBe(2) + }) + + it('exits 1 when no key is configured', async () => { + mocks.profileFrom.mockReturnValue( + configured({ apiKey: null, sources: { ...configured().sources, apiKey: 'unset' } }) + ) + + await whoami() + + expect(mocks.request).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + + it('makes no request and stays offline under --no-verify', async () => { + mocks.profileFrom.mockReturnValue(configured({ output: 'json' })) + + await whoami('--no-verify') + + expect(mocks.request).not.toHaveBeenCalled() + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(String(vi.mocked(console.log).mock.calls[0][0]))).toMatchObject({ + verification: { status: 'disabled', workspace: null }, + }) + }) }) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 242683ca644..71147ddf9b3 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -12,14 +12,16 @@ import { credentialsPath, deleteProfile, listProfiles, + type ResolvedProfile, readCredentialsProfile, type SettingSource, writeConfigProfile, writeCredentialsProfile, } from '../config/index' -import { profileFrom } from '../context' -import { SimApiError } from '../http/client' -import { printRecord } from '../output/render' +import { clientFrom, profileFrom } from '../context' +import { type GetWorkspaceResponse, V2_OPERATIONS } from '../generated/v2-api' +import { resolvePath, SimApiError, type SimClient } from '../http/client' +import { printRecord, safeOneLine } from '../output/render' /** * Best-effort browser launch. Failure is not an error: the URL is always printed @@ -203,14 +205,147 @@ export function logoutCommand(): Command { }) } +interface VerifiedWorkspace { + id: string + name: string + memberCount: number +} + +/** + * The outcome of checking the resolved settings against the API. + * + * Split by cause rather than into a boolean because each cause has a different + * fix, and `whoami` exists to name that fix: a rejected key needs a new login, a + * missing workspace needs `sim configure`, and an unreachable endpoint needs + * neither. + */ +type Verification = + | { status: 'verified'; workspace: VerifiedWorkspace; detail: null } + | { + status: 'rejected' | 'unreachable' | 'unauthenticated' | 'no-workspace' | 'disabled' + workspace: null + detail: string + } + +/** + * The only answers that are a verdict on the credentials themselves. + * + * 401 and 403 are the server judging the key; 404 means the configured + * workspace is not one this key can see. Everything else — a 502 from a proxy + * mid-deploy, a 429, a transport failure (status 0), an endpoint answering 200 + * with a login page — says nothing about the key, and calling it `rejected` + * told a user to run `sim login` for something logging in cannot fix. That is + * the flaky-VPN confusion the exit-code split exists to prevent. + */ +const CREDENTIAL_VERDICT_STATUSES = new Set([401, 403, 404]) + +/** + * `whoami` is the command people run to answer "am I set up correctly?", so the + * exit status has to carry that answer — reporting a junk key with exit 0 is the + * defect this mapping closes. + * + * 1 is the CLI's blanket "explained failure" code and means the credentials + * themselves are wrong. 2 is reserved for a check that could not be made at all: + * that is a different fix — retrying or setting a workspace helps, logging in + * again does not — and a script must be able to tell the two apart. + */ +const WHOAMI_EXIT_CODES = { + verified: 0, + disabled: 0, + unauthenticated: 1, + rejected: 1, + unreachable: 2, + 'no-workspace': 2, +} as const satisfies Record + +/** + * Confirms the resolved key really works, by reading the profile's own + * workspace. + * + * `getWorkspace` is the check because it is the cheapest read that proves all + * three settings at once — the endpoint answers, the key is accepted, and the + * key can reach the configured workspace — and because it comes back with the + * workspace's *name*, which is what tells a user the id they pasted is the + * workspace they meant. + * + * It is workspace-scoped, so a profile with no workspace has nothing to check + * against. That is reported rather than papered over with an account-scoped call + * a workspace-bound key would fail for reasons having nothing to do with its + * validity. + */ +async function verifyProfile( + client: Pick, + profile: ResolvedProfile +): Promise { + if (!profile.apiKey) { + return { + status: 'unauthenticated', + workspace: null, + detail: `no API key — run: sim login --profile ${profile.name}`, + } + } + if (!profile.workspaceId) { + return { + status: 'no-workspace', + workspace: null, + detail: `no workspace to check against — run: sim configure --profile ${profile.name} --set-workspace `, + } + } + + const operation = V2_OPERATIONS.getWorkspace + try { + const response = await client.request( + resolvePath(operation.path, { workspaceId: profile.workspaceId }), + { method: operation.method } + ) + const { id, name, memberCount } = response.data + // Projected field by field: the record carries display fields the machine + // output has no business inventing a contract for. + return { status: 'verified', workspace: { id, name, memberCount }, detail: null } + } catch (error) { + if (!(error instanceof SimApiError)) throw error + return { + status: CREDENTIAL_VERDICT_STATUSES.has(error.status) ? 'rejected' : 'unreachable', + workspace: null, + detail: error.message, + } + } +} + +function presentVerification(verification: Verification): string { + if (verification.status === 'verified') { + const { name, memberCount } = verification.workspace + const members = `${memberCount} ${memberCount === 1 ? 'member' : 'members'}` + // The name is server-supplied and lands in a terminal unescaped otherwise. + return `${chalk.green('✓')} ${safeOneLine(name)} · ${members}` + } + + const detail = safeOneLine(verification.detail) + switch (verification.status) { + case 'rejected': + return `${chalk.red('✗')} ${detail}` + case 'unauthenticated': + return chalk.yellow(`not logged in — ${detail}`) + case 'disabled': + return chalk.dim(detail) + default: + return chalk.yellow(`could not check — ${detail}`) + } +} + export function whoamiCommand(): Command { return new Command('whoami') - .description('Show the resolved profile and where each setting came from') - .action((_options: unknown, command: Command) => { - const profile = profileFrom(command) + .description('Show the resolved profile, where each setting came from, and whether it works') + .option('--no-verify', 'Skip the API check and only print the resolved settings') + .action(async (options: { verify: boolean }, command: Command) => { + const { client, profile } = clientFrom(command) const { sources } = profile const authentication = presentAuthentication(sources.apiKey) + const verification: Verification = options.verify + ? await verifyProfile(client, profile) + : { status: 'disabled', workspace: null, detail: 'not checked (--no-verify)' } + const annotate = (value: string, source: string) => source === 'unset' ? chalk.dim('not set') : `${value} ${chalk.dim(`(${source})`)}` @@ -227,6 +362,7 @@ export function whoamiCommand(): Command { ], ['Workspace', annotate(profile.workspaceId ?? '', sources.workspaceId)], ['Output', annotate(profile.output, sources.output)], + ['Verified', presentVerification(verification)], ], { profile: profile.name, @@ -240,8 +376,18 @@ export function whoamiCommand(): Command { workspaceId: sources.workspaceId, output: sources.output, }, + verification: { + status: verification.status, + workspace: verification.workspace, + detail: verification.detail, + }, } ) + + // Set rather than thrown: the resolved settings above are the answer the + // user came for, and a thrown error would replace them with one red line. + const exitCode = WHOAMI_EXIT_CODES[verification.status] + if (exitCode !== 0) process.exitCode = exitCode }) } diff --git a/packages/sim-cli/src/commands/configure.test.ts b/packages/sim-cli/src/commands/configure.test.ts new file mode 100644 index 00000000000..4a4bbb64080 --- /dev/null +++ b/packages/sim-cli/src/commands/configure.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readConfigProfile } from '../config/index' +import { configureCommand } from './configure' + +vi.mock('../context', () => ({ + profileFrom: () => ({ name: 'default' }), +})) + +let dir: string + +function run(...args: string[]): Promise { + const root = new Command('sim').exitOverride() + root.addCommand(configureCommand()) + return root.parseAsync(['node', 'sim', 'configure', ...args]) +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-cli-')) + process.env.SIM_CONFIG_DIR = dir + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(dir, { recursive: true, force: true }) + process.env.SIM_CONFIG_DIR = undefined +}) + +describe('configure --set-endpoint', () => { + it('refuses to store an endpoint that would later crash the URL parser', async () => { + await expect(run('--set-endpoint', 'not-a-url')).rejects.toThrow( + 'Invalid endpoint "not-a-url" from --set-endpoint. Use an absolute URL, e.g. https://sim.ai or http://localhost:3000' + ) + expect(readConfigProfile('default')).toEqual({}) + }) + + it('refuses a scheme the HTTP client cannot speak', async () => { + await expect(run('--set-endpoint', 'ftp://x.com')).rejects.toThrow( + 'Unsupported endpoint scheme "ftp" from --set-endpoint. Use http or https, e.g. https://sim.ai' + ) + expect(readConfigProfile('default')).toEqual({}) + }) + + it('stores a self-hosted endpoint with its trailing slashes stripped', async () => { + await run('--set-endpoint', 'http://localhost:3000//') + expect(readConfigProfile('default')).toMatchObject({ endpoint: 'http://localhost:3000' }) + }) +}) diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts index 88879206b55..265cc0b4ecb 100644 --- a/packages/sim-cli/src/commands/configure.ts +++ b/packages/sim-cli/src/commands/configure.ts @@ -1,6 +1,7 @@ import chalk from 'chalk' import { Command } from 'commander' import { configPath, OUTPUT_FORMATS, readConfigProfile, writeConfigProfile } from '../config/index' +import { normalizeEndpoint } from '../config/profile' import { profileFrom } from '../context' import { SimApiError } from '../http/client' @@ -29,7 +30,9 @@ export function configureCommand(): Command { const profile = profileFrom(command) const updates: Record = {} - if (options.setEndpoint) updates.endpoint = options.setEndpoint.replace(/\/+$/, '') + if (options.setEndpoint) { + updates.endpoint = normalizeEndpoint(options.setEndpoint, '--set-endpoint') + } if (options.setWorkspace) updates.workspace = options.setWorkspace if (options.setOutput) { if (!(OUTPUT_FORMATS as readonly string[]).includes(options.setOutput)) { diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts index c56ef989fd8..d11eeeb1c7f 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -139,4 +139,29 @@ describe('files upload', () => { }) expect(logged[0]).not.toContain('secret-token') }) + + it('encodes the destination folder, which the local path must never be', async () => { + // This command builds its own body, so it never reached the encoder every + // contract-driven `--folder` goes through: the same flag, the same value, + // accepted by `files list` and rejected here as non-canonical. + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + mockRequest + .mockResolvedValueOnce({ + data: { + session: { id: 'upload_1' }, + uploadToken: 'secret-token', + transfer: { method: 'put', url: 'https://storage.example/file', headers: {} }, + }, + }) + .mockResolvedValueOnce({ data: { file: { id: 'file_1' } } }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'file', 'upload', path, '--folder', '/Q1 (draft)']) + + expect(mockRequest.mock.calls[0][1].body).toMatchObject({ + folderPath: '/Q1%20%28draft%29', + }) + }) }) diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index c276b0b77d9..33a0a13209a 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -2,6 +2,7 @@ import type { Command } from 'commander' import { clientFrom } from '../../context' import type { CompleteFileUploadResponse, CreateFileUploadResponse } from '../../generated/v2-api' import { V2_OPERATIONS } from '../../generated/v2-api' +import { encodeFolderPath } from '../../runtime/request' import { contentTypeFor, localFile } from '../../transfer/local-file' import { finishUploadSession } from '../../transfer/upload-session' import { printProtocolResult } from './result' @@ -11,7 +12,7 @@ export function attachFileUpload(files: Command): void { .command('upload') .argument('', 'Local file to upload') .description('Upload a file to the workspace') - .option('--folder ', 'Destination folder path (defaults to /)') + .option('--folder ', 'Folder path as shown in the app; defaults to the root folder') .option('--name ', 'Store it under a different name') .action(async (path: string, options: { folder?: string; name?: string }, command: Command) => { const { client, profile } = clientFrom(command) @@ -27,7 +28,11 @@ export function attachFileUpload(files: Command): void { name, contentType: contentTypeFor(name), size, - ...(options.folder !== undefined ? { folderPath: options.folder } : {}), + // `` above is a LOCAL file and must stay untouched; only the + // destination folder is a wire-encoded API path. + ...(options.folder !== undefined + ? { folderPath: encodeFolderPath(options.folder) } + : {}), }, } ) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts index 1ef6d4b1d12..5f8558c2acb 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -144,6 +144,76 @@ describe('resource directory', () => { }) }) + it('encodes the path both commands take, as every contract-driven flag does', async () => { + // These two build their own request, so `buildRequest`'s encoding never ran + // for them: `--folder '/Folder 1'` worked while `ls '/Folder 1'` was + // rejected as non-canonical, and `mkdir` disagreed with the `folders + // create` the README calls its long form. + mockRequest.mockResolvedValue({ data: [], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'table', 'ls', '/Q1 (draft)']) + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { + query: expect.objectContaining({ parentPath: '/Q1%20%28draft%29' }), + }) + + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: { folder: {} } }) + await program().parseAsync(['node', 'sim', 'table', 'mkdir', '/Q1 (draft)']) + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { + method: 'POST', + body: { workspaceId: 'ws_local', path: '/Q1%20%28draft%29' }, + }) + }) + + it('decodes folder paths for the human formats but leaves json on the wire form', async () => { + // `ls` builds its own columns, so the contract's `folder-path` display + // format never reached it: the sibling `folders list` printed `/Folder 2` + // while `ls` printed `/Folder%202` for the same folder, one column away + // from the decoded `name` it prints beside it. + mockRequest.mockImplementation(async (path: string) => { + if (path === '/api/v2/tables/folders') { + return { + data: [ + { + name: 'New folder', + path: '/Folder%202/New%20folder', + parentPath: '/Folder%202', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + ], + nextCursor: null, + } + } + return { + data: [ + { + id: 'tbl_1', + name: 'Revenue', + folderPath: '/Folder%202', + updatedAt: '2026-08-03T00:00:00.000Z', + }, + ], + nextCursor: null, + } + }) + + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + output.format = 'text' + await program().parseAsync(['node', 'sim', 'table', 'ls', '/Folder 2']) + expect(logged.join('\n')).toContain('/Folder 2/New folder') + expect(logged.join('\n')).not.toContain('%20') + + logged.length = 0 + output.format = 'json' + await program().parseAsync(['node', 'sim', 'table', 'ls', '/Folder 2']) + const entries = JSON.parse(logged[0]) as Array<{ kind: string; ref: string }> + expect(entries.find((entry) => entry.kind === 'folder')?.ref).toBe('/Folder%202/New%20folder') + expect(entries.find((entry) => entry.kind === 'table')?.ref).toBe('tbl_1') + }) + it('rejects extra directory arguments instead of silently ignoring them', async () => { await expect( program().parseAsync(['node', 'sim', 'file', 'ls', 'Reports', 'ignored']) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index daf20cc6c88..58d93eeef20 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -15,7 +15,8 @@ import { import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client' import { type Column, printList, text, timestamp } from '../../output/render' import { DEFAULT_LIMIT } from '../../runtime/options' -import { renderResult } from '../../runtime/result' +import { encodeFolderPath } from '../../runtime/request' +import { decodeFolderPath, renderResult } from '../../runtime/result' type FolderListOperation = | 'listFileFolders' @@ -74,11 +75,19 @@ interface ListOptions { limit: string } +/** + * A folder's `ref` is its path, so it decodes like one; a resource's `ref` is an + * opaque id and is shown as it arrived. Both stay pasteable into the next + * command because `encodeFolderPath` accepts either form. + */ const COLUMNS: Column[] = [ { header: 'kind', value: (entry) => text(entry.kind) }, { header: 'name', value: (entry) => text(entry.name) }, - { header: 'ref', value: (entry) => text(entry.ref) }, - { header: 'folder', value: (entry) => text(entry.folderPath) }, + { + header: 'ref', + value: (entry) => text(entry.kind === 'folder' ? decodeFolderPath(entry.ref) : entry.ref), + }, + { header: 'folder', value: (entry) => text(decodeFolderPath(entry.folderPath)) }, { header: 'updated', value: (entry) => timestamp(entry.updatedAt) }, ] @@ -170,7 +179,10 @@ export function attachResourceDirectoryCommands( } const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit - const folderPath = path ?? '/' + // These commands build their own request, so the encoding `buildRequest` + // applies to every contract-driven folder flag has to be applied here too + // — otherwise `--folder '/Folder 1'` works and `ls '/Folder 1'` does not. + const folderPath = encodeFolderPath(path ?? '/') const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() const [folders, resources] = await Promise.all([ @@ -191,7 +203,7 @@ export function attachResourceDirectoryCommands( const operation = V2_OPERATIONS[config.createFolder] const result = await client.request<{ data?: unknown }>(operation.path, { method: operation.method, - body: { workspaceId: client.requireWorkspace(), path }, + body: { workspaceId: client.requireWorkspace(), path: encodeFolderPath(path) }, }) renderResult(config.createFolder, profile.output, result.data ?? result, {}) }) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index b14cee1a0dd..9e35d380c11 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -127,4 +127,25 @@ describe('tables import output', () => { }) expect(logged[0]).not.toContain('uploadToken') }) + + it('encodes the destination folder the same way every other --folder is', async () => { + mockRequest.mockResolvedValue({ + data: { session: { id: 'import_1', status: 'queued' }, uploadToken: null, transfer: null }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runImport([ + '--file-id', + 'f_1', + '--name', + 'Customers', + '--folder', + '/Q1 (draft)', + '--no-wait', + ]) + + expect(mockRequest.mock.calls[0][1].body.target).toMatchObject({ + folderPath: '/Q1%20%28draft%29', + }) + }) }) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 4d64f5b3ed2..b6d1ae47c41 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -9,7 +9,7 @@ import type { } from '../../generated/v2-api' import { V2_OPERATIONS } from '../../generated/v2-api' import { SimApiError, type SimClient } from '../../http/client' -import { coerce, type FieldSpec } from '../../runtime/request' +import { coerce, encodeFolderPath, type FieldSpec } from '../../runtime/request' import { contentTypeFor, localFile } from '../../transfer/local-file' import { finishUploadSession } from '../../transfer/upload-session' import { printProtocolResult } from './result' @@ -108,7 +108,7 @@ export function attachTableImport(tables: Command): void { 'How to write into --table-id (default: append)' ).choices(['append', 'replace']) ) - .option('--folder ', 'Folder path for the new table') + .option('--folder ', 'Folder path for the new table, as shown in the app') .option('--file-id ', 'Import a file already in the workspace instead of a local path') .option('--mapping ', 'Column mapping (--table-id only)') .option('--create-columns ', 'Columns to create (--table-id only)') @@ -144,7 +144,7 @@ export function attachTableImport(tables: Command): void { target = { type: 'new', name, - ...(options.folder !== undefined ? { folderPath: options.folder } : {}), + ...(options.folder !== undefined ? { folderPath: encodeFolderPath(options.folder) } : {}), } } diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 225af15842b..0e9d13d3d87 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -96,6 +96,36 @@ describe('profile resolution', () => { expect(resolveProfile({ endpoint: 'https://sim.ai///' }).endpoint).toBe('https://sim.ai') }) + it('fails fast on an endpoint Node cannot parse, naming the source', () => { + expect(() => resolveProfile({ endpoint: 'not-a-url' })).toThrow( + 'Invalid endpoint "not-a-url" from flag. Use an absolute URL, e.g. https://sim.ai or http://localhost:3000' + ) + + process.env.SIM_ENDPOINT = 'not-a-url' + expect(() => resolveProfile()).toThrow('Invalid endpoint "not-a-url" from env.') + + Reflect.deleteProperty(process.env, 'SIM_ENDPOINT') + writeConfigProfile('default', { endpoint: 'not-a-url' }) + expect(() => resolveProfile()).toThrow('Invalid endpoint "not-a-url" from config.') + }) + + it('rejects a parseable endpoint the HTTP client could never call', () => { + expect(() => resolveProfile({ endpoint: 'ftp://x.com' })).toThrow( + 'Unsupported endpoint scheme "ftp" from flag. Use http or https, e.g. https://sim.ai' + ) + }) + + it('accepts every endpoint shape a self-hosted install needs', () => { + for (const endpoint of [ + 'http://localhost:3000', + 'https://10.0.0.7:8443', + 'https://sim.internal:8080/sim', + 'http://127.0.0.1:3000/', + ]) { + expect(resolveProfile({ endpoint }).endpoint).toBe(endpoint.replace(/\/+$/, '')) + } + }) + it('fails fast on an unrecognized active output format', () => { process.env.SIM_OUTPUT = 'xml' expect(() => resolveProfile()).toThrow( diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index c770cc2aae9..8de5402d580 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -130,10 +130,38 @@ export function deleteProfile(profile: string): { config: boolean; credentials: return { config, credentials } } -function normalizeEndpoint(endpoint: string): string { +/** + * Validates an endpoint and strips its trailing slashes. + * + * The check has to live here rather than at the call sites because an endpoint + * reaches the HTTP client from four directions — `--endpoint`, `SIM_ENDPOINT`, + * `configure --set-endpoint`, and a hand-edited `~/.sim/config` — and an + * unparseable one escapes as a raw `TypeError: Invalid URL` stack trace from + * inside Node's URL parser instead of a CLI error. + * + * `source` names where the value came from, so the message points at the thing + * the user has to edit. + */ +export function normalizeEndpoint(endpoint: string, source: string): string { // A trailing slash here produces `https://sim.ai//api/v2/...`, which some // proxies 404 rather than normalize. - return endpoint.replace(/\/+$/, '') + const trimmed = endpoint.replace(/\/+$/, '') + + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + throw new ProfileConfigError( + `Invalid endpoint "${endpoint}" from ${source}. Use an absolute URL, e.g. ${DEFAULT_ENDPOINT} or http://localhost:3000` + ) + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new ProfileConfigError( + `Unsupported endpoint scheme "${parsed.protocol.replace(/:$/, '')}" from ${source}. Use http or https, e.g. ${DEFAULT_ENDPOINT}` + ) + } + + return trimmed } /** @@ -205,7 +233,7 @@ export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfil return { name, - endpoint: normalizeEndpoint(endpoint.value as string), + endpoint: normalizeEndpoint(endpoint.value as string, endpoint.source), apiKey: apiKey.value, workspaceId: workspaceId.value, output: output.value as OutputFormat, diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts new file mode 100644 index 00000000000..c537ecbaee3 --- /dev/null +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -0,0 +1,178 @@ +/** + * @vitest-environment node + */ +import type { Command } from 'commander' +import { describe, expect, it } from 'vitest' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { buildGeneratedCommands } from '../runtime/build' +import { flagNameFor, flagSpecFor } from '../runtime/request' +import type { OperationSpec } from '../runtime/types' +import { CLI_CONTRACT } from './commands' + +/** Every leaf command's full path, `tables rows count` style. */ +function leafPaths(options: { includeHidden?: boolean } = {}): string[] { + const paths: string[] = [] + const isHidden = (command: Command) => + (command as Command & { _hidden?: boolean })._hidden === true + const walk = (command: Command, prefix: string[]): void => { + const path = [...prefix, command.name()] + const children = options.includeHidden + ? command.commands + : command.commands.filter((child) => !isHidden(child)) + if (children.length === 0) { + paths.push(path.join(' ')) + return + } + for (const child of children) walk(child, path) + } + for (const group of buildGeneratedCommands()) walk(group, []) + return paths +} + +function commandAt(...names: string[]): Command { + let current: Command | undefined + let candidates: readonly Command[] = buildGeneratedCommands() + for (const name of names) { + current = candidates.find((command) => command.name() === name) + if (!current) throw new Error(`Missing command ${names.join(' ')}`) + candidates = current.commands + } + if (!current) throw new Error('No command requested') + return current +} + +describe('the command tree', () => { + it('registers every command name exactly once', () => { + // Commander resolves a duplicate name to the first registered match, so a + // collision does not fail loudly — the shadowed command's flags simply + // become unreachable, which is how the bulk document update once hid the + // single-document one. + const paths = leafPaths() + expect(paths.length).toBe(new Set(paths).size) + }) + + it('names each renamed command after what it does', () => { + // The retired path still resolves, so a script written before the rename + // keeps working; it is simply hidden, so nothing teaches it any more. Both + // halves matter: dropping it breaks callers, surfacing it undoes the rename. + const visible = leafPaths() + const all = leafPaths({ includeHidden: true }) + + for (const [current, retired] of [ + ['tables rows count', 'tables count create'], + ['files restore', 'files restore create'], + ['workflows deployment status', 'workflows deployment list'], + ]) { + expect(visible).toContain(current) + expect(visible).not.toContain(retired) + expect(all).toContain(retired) + } + }) + + it('spells one concept with one flag name across the contract', () => { + // `predicate` was `--filter` on two row commands and `--predicate` on the + // third, and the same idea was `--q` here and `--query` on knowledge search. + const flagsByField = new Map>() + for (const operation of Object.keys(V2_OPERATIONS) as V2OperationName[]) { + if (CLI_CONTRACT[operation]?.hidden) continue + const spec = V2_OPERATIONS[operation] as OperationSpec + for (const slot of ['query', 'body'] as const) { + for (const field of Object.keys(spec[slot] ?? {})) { + if (flagSpecFor(operation, field).omit) continue + const names = flagsByField.get(field) ?? new Set() + names.add(flagNameFor(operation, field)) + flagsByField.set(field, names) + } + } + } + + const divergent = [...flagsByField] + .filter(([, names]) => names.size > 1) + .map(([field, names]) => `${field}: ${[...names].sort().join(', ')}`) + + // `rowIds` is the one field still spelled two ways: `tables rows + // batch-delete` deliberately takes a singular repeated `--row`. + expect(divergent).toEqual(['rowIds: row, row-ids']) + }) +}) + +describe('renamed commands keep their surface', () => { + it('documents the filter operators on the row count', () => { + const help = commandAt('tables', 'rows', 'count').helpInformation() + expect(help).toContain('--filter ') + expect(help).toContain('{"all":[{"field":"status","op":"eq","value":"active"}]}') + expect(help).not.toContain('--predicate') + }) + + it('asks for a row search the same way knowledge search does', () => { + const help = commandAt('tables', 'rows', 'find').helpInformation() + expect(help).toContain('--query ') + expect(help).not.toMatch(/--q\b/) + }) + + it('names the parent knowledge base on every document command', () => { + for (const verb of ['get', 'update', 'delete', 'batch-update']) { + expect(commandAt('knowledge', 'documents', verb).helpInformation()).toContain( + '' + ) + } + expect(commandAt('knowledge', 'tags', 'list').helpInformation()).toContain('') + }) +}) + +/** + * Field names the v2 contract uses for a folder path. + * + * Only ever consulted here, to prove the contract marks all of them: the CLI + * itself drives off the explicit `folderPath` marker, because `path` on its + * own is also a LOCAL file on the upload commands. + */ +const FOLDER_PATH_FIELDS = new Set([ + 'folderPath', + 'folderPaths', + 'parentPath', + 'destinationPath', + 'targetFolderPath', + 'path', +]) + +describe('folder-path fields', () => { + it('marks every one of them for encoding', () => { + // One missed field is one command where the visible folder name is still + // rejected, and nothing about the failure would point back here. + const unmarked: string[] = [] + let checked = 0 + for (const operation of Object.keys(V2_OPERATIONS) as V2OperationName[]) { + const spec = V2_OPERATIONS[operation] as OperationSpec + // A hidden operation never reaches `buildRequest`; the bespoke command + // driving it (`files upload`) builds its own body and calls the encoder + // itself, so a marker here would claim an encoding this path never runs. + // That call is covered by the command's own test. + if (CLI_CONTRACT[operation]?.hidden) continue + for (const slot of ['query', 'body'] as const) { + for (const field of Object.keys(spec[slot] ?? {})) { + if (!FOLDER_PATH_FIELDS.has(field)) continue + checked += 1 + if (flagSpecFor(operation, field).folderPath !== true) { + unmarked.push(`${operation}.${field}`) + } + } + } + } + expect(unmarked).toEqual([]) + expect(checked).toBeGreaterThan(30) + }) + + it('decodes every one it also puts in a column', () => { + const undecoded: string[] = [] + for (const [operation, spec] of Object.entries(CLI_CONTRACT)) { + for (const column of [...(spec.columns ?? []), ...(spec.fields ?? [])]) { + const path = column.path ?? column.header + if (FOLDER_PATH_FIELDS.has(path) && column.format !== 'folder-path') { + undecoded.push(`${operation}.${path}`) + } + } + } + expect(undecoded).toEqual([]) + }) +}) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 4c7119c800f..8e522b3f37a 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -7,8 +7,17 @@ const TABLE_SORT_HELP = 'Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc)' const CUSTOM_TOOL_SCHEMA_HELP = 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' +/** + * Every folder-path input the API accepts. + * + * `folderPath` is what marks the field for per-segment encoding, so a folder is + * typed by the name the app shows it under. It belongs on the shared constant + * rather than on each of the thirty-odd fields, because one that was missed + * would silently be the only place `/Folder 1` is still rejected. + */ const FOLDER_PATH_INPUT = { - describe: 'Folder path; the leading / is optional', + describe: 'Folder path as shown in the app; the leading / is optional', + folderPath: true, } as const const FOLDER_PATH_FLAG = { ...FOLDER_PATH_INPUT, @@ -18,7 +27,7 @@ const FOLDER_DELETE_FLAGS = { path: FOLDER_PATH_INPUT, recursive: { boolean: true, describe: 'Delete the folder and its descendants' }, } as const -const KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS = { id: 'knowledgeBaseId' } as const +const KNOWLEDGE_BASE_PATH_ARGUMENT = { id: 'knowledgeBaseId' } as const const WORKFLOW_RUN_SCOPE = { id: { name: 'workflow', @@ -26,10 +35,11 @@ const WORKFLOW_RUN_SCOPE = { describe: 'Workflow ID', }, } as const +const FOLDER_COLUMN: ColumnSpec = { header: 'folder', path: 'folderPath', format: 'folder-path' } const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ - { header: 'path' }, + { header: 'path', format: 'folder-path' }, { header: 'name' }, - { header: 'parent', path: 'parentPath' }, + { header: 'parent', path: 'parentPath', format: 'folder-path' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ] @@ -123,7 +133,7 @@ export const CLI_CONTRACT: CliContract = { bulkUpdateKnowledgeDocuments: { command: 'knowledge documents batch-update', describe: 'Enable or disable every matching document', - pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, + pathArgumentNames: KNOWLEDGE_BASE_PATH_ARGUMENT, flags: { documentIds: { name: 'document', list: true }, selectAll: { boolean: true, describe: 'Apply to every document in the knowledge base' }, @@ -134,6 +144,15 @@ export const CLI_CONTRACT: CliContract = { command: 'workflows undeploy', describe: 'Take a workflow out of deployment', }, + // `GET /workflows/[id]/deployment` is a collection-shaped path holding one + // record, so the derived `list` promised a page of deployments there is no + // such thing as. `status` is what the singular group beside `versions` can be + // asked for. + getWorkflowDeployment: { + command: 'workflows deployment status', + renamedFrom: ['workflows deployment list'], + describe: 'Show a workflow’s current deployment', + }, setSecret: { hidden: true }, // ─── Destructive single-resource operations ─────────────────────────────── @@ -145,7 +164,7 @@ export const CLI_CONTRACT: CliContract = { }, deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, deleteKnowledgeDocument: { - pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, + pathArgumentNames: KNOWLEDGE_BASE_PATH_ARGUMENT, confirm: 'This deletes the document and its embeddings.', }, deleteFile: { confirm: 'This archives the file.' }, @@ -179,7 +198,14 @@ export const CLI_CONTRACT: CliContract = { workflowIds: { name: 'workflow', list: true }, folderPaths: { ...FOLDER_PATH_FLAG, list: true }, triggers: { name: 'trigger', list: true }, - details: { describe: 'Response detail level' }, + // The `workflow` column below reads `workflow.name`, which the API only + // sends at `full` — at its own `basic` default every row's workflow was an + // em-dash and a run had nothing naming what ran. Asked for by default so + // the declared columns can be filled; an explicit `--details basic` wins. + details: { + requestDefault: 'full', + describe: 'Response detail level; full is requested by default to name each run’s workflow', + }, includeTraceSpans: { boolean: true, describe: 'Include trace spans in JSON or YAML output (implies full detail)', @@ -313,7 +339,7 @@ export const CLI_CONTRACT: CliContract = { columns: [ { header: 'id' }, { header: 'name' }, - { header: 'folder', path: 'folderPath' }, + FOLDER_COLUMN, { header: 'rows', path: 'rowCount' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], @@ -323,7 +349,7 @@ export const CLI_CONTRACT: CliContract = { columns: [ { header: 'id' }, { header: 'name' }, - { header: 'folder', path: 'folderPath' }, + FOLDER_COLUMN, { header: 'deployed', path: 'isDeployed', format: 'bool' }, { header: 'runs', path: 'runCount' }, { header: 'last run', path: 'lastRunAt', format: 'timestamp' }, @@ -336,7 +362,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'name' }, // Now that files live in folders, which one is the difference between two // identically-named rows. - { header: 'folder', path: 'folderPath' }, + FOLDER_COLUMN, { header: 'size', format: 'bytes' }, { header: 'type' }, { header: 'uploaded by', path: 'uploadedByEmail' }, @@ -349,15 +375,21 @@ export const CLI_CONTRACT: CliContract = { columns: [ { header: 'id' }, { header: 'name' }, - { header: 'folder', path: 'folderPath' }, + FOLDER_COLUMN, { header: 'docs', path: 'docCount' }, { header: 'tokens', path: 'tokenCount' }, { header: 'model', path: 'embeddingModel' }, ], }, - getKnowledgeDocument: { pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS }, + // Every command whose `[id]` is the parent knowledge base rather than the + // thing being acted on names it in its own help and error messages. `update` + // and `tags list` were left out, so the same value was `` on one command + // and `` on its neighbours. + getKnowledgeDocument: { pathArgumentNames: KNOWLEDGE_BASE_PATH_ARGUMENT }, + updateKnowledgeDocument: { pathArgumentNames: KNOWLEDGE_BASE_PATH_ARGUMENT }, + listKnowledgeTags: { pathArgumentNames: KNOWLEDGE_BASE_PATH_ARGUMENT }, listKnowledgeDocuments: { - pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, + pathArgumentNames: KNOWLEDGE_BASE_PATH_ARGUMENT, columns: [ { header: 'id' }, { header: 'filename' }, @@ -461,9 +493,9 @@ export const CLI_CONTRACT: CliContract = { }, // ─── The expanded files surface ─────────────────────────────────────────── - // Every one of these derives badly. `/files/move` and `/files/bulk-delete` - // are verbs sitting where the deriver expects a sub-resource, so it made them - // groups holding a lone `create`. + // Every one of these derives badly. `/files/move`, `/files/bulk-delete` and + // `/files/[fileId]/restore` are verbs sitting where the deriver expects a + // sub-resource, so it made them groups holding a lone `create`. bulkDeleteFiles: { // `batch-` for the bulk form, matching `tables rows batch-delete`. command: 'files batch-delete', @@ -481,7 +513,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'name' }, { header: 'size', format: 'bytes' }, { header: 'type' }, - { header: 'folder', path: 'folderPath' }, + FOLDER_COLUMN, { header: 'uploaded by', path: 'uploadedByEmail' }, { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, @@ -511,6 +543,13 @@ export const CLI_CONTRACT: CliContract = { command: 'files rename', describe: 'Rename a file', }, + restoreFile: { + // `files restore create` created nothing; it is the inverse of the delete + // that archived the file. + command: 'files restore', + renamedFrom: ['files restore create'], + describe: 'Restore an archived file', + }, updateFileContent: { command: 'files set-content', describe: 'Replace a file’s contents', @@ -659,9 +698,9 @@ export const CLI_CONTRACT: CliContract = { }, // ─── The expanded tables surface ────────────────────────────────────────── - // `/cancel-runs`, `/rows/find`, `/columns/run` and the enrichment path all put - // a verb where the deriver expects a sub-resource, so each became - // a group holding a lone `create`. + // `/cancel-runs`, `/rows/find`, `/query/count`, `/columns/run` and the + // enrichment path all put a verb where the deriver expects a sub-resource, so + // each became a group holding a lone `create`. cancelTableRuns: { command: 'tables cancel-runs', describe: 'Stop every running column job', @@ -674,13 +713,30 @@ export const CLI_CONTRACT: CliContract = { command: 'tables rows find', describe: 'Find rows matching a predicate', flags: { - q: { describe: 'Value to find' }, + // `--q` was the wire field spelled out; the same idea is `--query` on + // `knowledge search`, and one concept should not have two flag names. + q: { name: 'query', renamedFrom: ['q'], describe: 'Value to find' }, predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, sort: { json: true, describe: TABLE_SORT_HELP }, }, itemsPath: 'matches', columns: [{ header: 'ordinal' }, { header: 'row', path: 'rowId' }, { header: 'column' }], }, + queryRowsCount: { + // `tables count create` counted rows and created nothing. The count is a + // question about rows, so it belongs beside the other row commands. + command: 'tables rows count', + renamedFrom: ['tables count create'], + describe: 'Count rows matching a filter', + flags: { + predicate: { + name: 'filter', + renamedFrom: ['predicate'], + json: true, + describe: TABLE_FILTER_HELP, + }, + }, + }, runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 736c247ef24..5a8edbd1dd0 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -34,6 +34,15 @@ export interface FlagSpec { name?: string /** Short alias, e.g. `w` for `--workspace`. */ short?: string + /** + * Flag names this field used to answer to, such as `predicate` before the + * count command's filter was spelled the same as its six siblings'. + * + * Kept only so an existing script does not break: hidden from help and from + * the generated docs, warns on stderr, and refuses when combined with the + * current spelling rather than silently picking one. + */ + renamedFrom?: readonly string[] /** * Accept one or more space-separated values, or `@path` / `@-` with one * value per line. @@ -52,10 +61,32 @@ export interface FlagSpec { json?: boolean /** Overrides the help text otherwise taken from the OpenAPI description. */ describe?: string + /** + * Value sent when the caller passes nothing, in place of the server's default. + * + * For a command whose declared `columns` read a field the API only sends at a + * heavier setting: `logs list` shows `workflow.name`, which `details=basic` + * omits, so the primary debugging table had a permanently empty column. It is + * a request default, not a flag default — whatever the caller types wins, + * including a deliberate `--details basic`. + */ + requestDefault?: string /** Accepted values when the generated descriptor cannot recover an enum. */ choices?: readonly string[] /** Expose a string-backed API boolean as a conventional terminal toggle. */ boolean?: true + /** + * This field carries a folder path, so percent-encode each of its segments. + * + * The API's canonical folder path is percent-encoded per segment, which made + * the terminal the only place a folder had to be spelled `/Folder%201` + * instead of the `/Folder 1` shown everywhere else; typing what you see was + * rejected with a message that never mentioned encoding. Marked rather than + * inferred from the field's name: `files upload` and `knowledge documents + * upload` take a `path` that is a LOCAL file, and encoding one of those would + * break the read. + */ + folderPath?: true /** * Never expose this field as a flag, and never send it. * @@ -85,8 +116,24 @@ export interface ColumnSpec { header: string /** Dot path into the row. Defaults to `header`. */ path?: string - /** Rendering hint; `auto` inspects the value. */ - format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' | 'count' | 'trace-count' + /** + * Rendering hint; `auto` inspects the value. + * + * `folder-path` is the display half of `FlagSpec.folderPath`: it undoes the + * wire encoding for the human formats, so a folder no longer prints as + * `/cli-test-a/nested%20one` in the same row as the `nested one` the server + * put in the adjacent name column. + */ + format?: + | 'auto' + | 'timestamp' + | 'bytes' + | 'duration' + | 'bool' + | 'cost' + | 'count' + | 'trace-count' + | 'folder-path' } export interface BodyVariantSpec { @@ -121,6 +168,16 @@ export interface CommandSpec { groupDefault?: boolean /** Alternate leaf command names, such as `ls` for `list`. */ aliases?: readonly string[] + /** + * Full command paths this operation used to answer to, such as + * `tables count create` before it became `tables rows count`. + * + * Unlike {@link aliases}, these are kept only so an existing script does not + * break: each is hidden from help and from the generated docs, and warns on + * stderr with the current spelling. Give the whole path, because a rename can + * move a command between groups rather than just retitle its leaf. + */ + renamedFrom?: readonly string[] /** Route path parameters exposed as required named options instead of positionals. */ pathFlags?: Record /** Friendly placeholders for route path parameters that remain positional. */ diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 39b5177e58e..eb56855742b 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -3,6 +3,7 @@ import { CLI_CONTRACT } from '../contract/commands' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { formatApiErrorDetails, + redirectEndpoint, requestAllPages, resolvePath, SimApiError, @@ -13,6 +14,41 @@ afterEach(() => { vi.unstubAllGlobals() }) +function client(options: { apiKey?: string } = { apiKey: 'key' }): SimClient { + return new SimClient({ + name: 'default', + endpoint: 'https://sim.example', + apiKey: options.apiKey ?? null, + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'env', + workspaceId: 'env', + output: 'default', + }, + }) +} + +function stubStderr(isTTY: boolean): { writes: string[]; restore: () => void } { + const writes: string[] = [] + const originalTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY') + const originalWrite = process.stderr.write + Object.defineProperty(process.stderr, 'isTTY', { configurable: true, value: isTTY }) + process.stderr.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stderr.write + return { + writes, + restore: () => { + process.stderr.write = originalWrite + if (originalTTY) Object.defineProperty(process.stderr, 'isTTY', originalTTY) + else Reflect.deleteProperty(process.stderr, 'isTTY') + }, + } +} + describe('cursor pagination', () => { it('follows v2 cursors through the requested item limit', async () => { const request = vi @@ -37,6 +73,291 @@ describe('cursor pagination', () => { auth: 'optional', }) }) + + it('reports progress on stderr once a second page is coming, then clears the line', async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ data: ['a', 'b'], nextCursor: 'next' }) + .mockResolvedValueOnce({ data: ['c'], nextCursor: null }) + const stderr = stubStderr(true) + + try { + await requestAllPages({ request } as Pick, '/api/v2/items', { + pageSize: 2, + }) + } finally { + stderr.restore() + } + + expect(stderr.writes).toHaveLength(2) + expect(stderr.writes[0]).toContain('fetched 2') + expect(stderr.writes[1]).toBe('\r\u001b[K') + }) + + describe('the endpoint a redirect implies', () => { + // Naming `target.origin` dropped a self-hosted endpoint's path prefix, so + // the suggested value was not an API root and following the advice broke a + // deployment that was one hostname away from working. + it('keeps a path prefix the endpoint carries', () => { + expect( + redirectEndpoint( + 'https://host/sim', + '/api/v2/workflows', + new URL('https://www.host/sim/api/v2/workflows') + ) + ).toBe('https://www.host/sim') + }) + + it('is just the origin when the endpoint has no prefix', () => { + expect( + redirectEndpoint( + 'https://sim.example', + '/api/v2/workflows', + new URL('https://www.sim.example/api/v2/workflows') + ) + ).toBe('https://www.sim.example') + }) + + it('implies no change when the target resolves to the endpoint already set', () => { + // A trailing-slash or path-normalization redirect keeps the origin; + // advising the value the caller already has explains nothing. + expect( + redirectEndpoint( + 'https://sim.example', + '/api/v2/workflows', + new URL('https://sim.example/api/v2/workflows/') + ) + ).toBeNull() + expect( + redirectEndpoint( + 'https://sim.example/', + '/api/v2/x', + new URL('https://sim.example/api/v2/x') + ) + ).toBeNull() + }) + + it('falls back to the origin when the target does not carry the request path', () => { + expect( + redirectEndpoint( + 'https://sim.example', + '/api/v2/workflows', + new URL('https://auth.example/login') + ) + ).toBe('https://auth.example') + }) + }) + + it('clears the progress line when a later page fails', async () => { + // Progress is written without a trailing newline so it can be overwritten in + // place. Cleaning up only on success left `fetched 2…` on the line the error + // was then printed onto, so the two ran together. + const request = vi + .fn() + .mockResolvedValueOnce({ data: ['a', 'b'], nextCursor: 'next' }) + .mockRejectedValueOnce(new Error('page two failed')) + const stderr = stubStderr(true) + + try { + await expect( + requestAllPages({ request } as Pick, '/api/v2/items', { + pageSize: 2, + }) + ).rejects.toThrow('page two failed') + } finally { + stderr.restore() + } + + expect(stderr.writes[0]).toContain('fetched 2') + expect(stderr.writes.at(-1)).toBe('\r\u001b[K') + }) + + it('stays silent for a single page, and when stderr is not a terminal', async () => { + const single = vi.fn().mockResolvedValue({ data: ['a'], nextCursor: null }) + const paged = vi + .fn() + .mockResolvedValueOnce({ data: ['a'], nextCursor: 'next' }) + .mockResolvedValueOnce({ data: ['b'], nextCursor: null }) + + const tty = stubStderr(true) + try { + await requestAllPages({ request: single } as Pick, '/items', { + pageSize: 2, + }) + } finally { + tty.restore() + } + expect(tty.writes).toEqual([]) + + const piped = stubStderr(false) + try { + await requestAllPages({ request: paged } as Pick, '/items', { + pageSize: 1, + }) + } finally { + piped.restore() + } + expect(piped.writes).toEqual([]) + }) +}) + +describe('redirects', () => { + function redirect(location: string | null, status = 301): Response { + return new Response(null, { + status, + headers: location === null ? {} : { location }, + }) + } + + it('does not let fetch follow a redirect, which would drop the write body', async () => { + const fetch = vi.fn().mockResolvedValue(redirect('https://www.sim.example/api/v2/tables')) + vi.stubGlobal('fetch', fetch) + + await expect( + client().request('/api/v2/tables/folders', { method: 'POST', body: { path: '/a' } }) + ).rejects.toThrow(/redirected to https:\/\/www\.sim\.example/) + expect(fetch.mock.calls[0][1].redirect).toBe('manual') + }) + + it('names the endpoint to switch to, derived from the Location origin', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(redirect('https://www.sim.example:8443/api/v2/tables?x=1', 308)) + ) + + await expect(client().request('/api/v2/tables')).rejects.toMatchObject({ + message: + 'Endpoint redirected to https://www.sim.example:8443. Run: sim configure --profile default --set-endpoint https://www.sim.example:8443', + status: 308, + }) + }) + + it('resolves a relative Location rather than string-hacking the endpoint', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(redirect('/api/v2/tables/'))) + + await expect(client().request('/api/v2/tables')).rejects.toThrow( + /redirected to https:\/\/sim\.example\/api\/v2\/tables\// + ) + }) + + it('still explains itself when Location is missing or unparseable', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(redirect(null, 302))) + await expect(client().request('/api/v2/tables')).rejects.toThrow(/no usable redirect target/) + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(redirect('http://'))) + await expect(client().request('/api/v2/tables')).rejects.toThrow(/no usable redirect target/) + }) +}) + +describe('non-JSON responses', () => { + it('names the URL and the shape instead of dumping a page of HTML', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('Example Domain', { + status: 404, + headers: { 'content-type': 'text/html; charset=UTF-8' }, + }) + ) + ) + + const failure = client().request('/api/v2/workflows') + + await expect(failure).rejects.toMatchObject({ + message: + 'https://sim.example/api/v2/workflows returned HTML, not JSON (HTTP 404) — check your endpoint.', + }) + await expect(failure).rejects.not.toThrow(/ { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('hello', { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + ) + + await expect(client().request('/api/v2/workflows')).rejects.toMatchObject({ + name: 'SimApiError', + message: + 'https://sim.example/api/v2/workflows returned HTML, not JSON (HTTP 200) — check your endpoint.', + }) + }) + + it("keeps a short plain-text body, which is the proxy's own diagnosis", async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('upstream connect error', { + status: 502, + headers: { 'content-type': 'text/plain' }, + }) + ) + ) + + await expect(client().request('/api/v2/workflows')).rejects.toThrow( + /returned text\/plain, not JSON \(HTTP 502\) — check your endpoint\. Response: upstream connect error/ + ) + }) + + it('leaves an empty error body reported by status alone', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 503 }))) + + await expect(client().request('/api/v2/workflows')).rejects.toMatchObject({ + message: 'Request failed with status 503', + }) + }) +}) + +describe('personal-key-only operations', () => { + it('appends the remedy, keyed off the code the API actually nests', async () => { + // The envelope this asserts is the one staging returns: `error.code` is the + // status class, and the actionable code rides in `error.details.code`. + // Fabricating it at the top level made a green test out of a dead branch. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: 'FORBIDDEN', + message: 'Workspace API key cannot perform this operation', + details: { code: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }, + }, + }), + { status: 403, headers: { 'content-type': 'application/json' } } + ) + ) + ) + + await expect(client().request('/api/v2/secrets')).rejects.toMatchObject({ + message: + 'Workspace API key cannot perform this operation — this operation needs a personal API key: sim login --profile default', + code: 'FORBIDDEN', + }) + }) + + it('invents no remedy for other forbidden codes', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ error: { code: 'FORBIDDEN', message: 'Insufficient permissions' } }), + { status: 403, headers: { 'content-type': 'application/json' } } + ) + ) + ) + + await expect(client().request('/api/v2/secrets')).rejects.toMatchObject({ + message: 'Insufficient permissions', + }) + }) }) describe('API errors', () => { @@ -109,28 +430,103 @@ describe('API errors', () => { expect(lines).toEqual([' details:', ' predicate.all.0.op: Expected one of eq, ne']) }) + it('drops the union branches the input did not take', () => { + // `keys` is part of the issue Zod emits and the route serializes verbatim, + // and it is the tell: a key rejected as unrecognized that another issue was + // found *inside* is a branch the input did not take, not a real complaint. + const lines = formatApiErrorDetails([ + { code: 'invalid_type', path: ['predicate', 'all', 0, 'all'], message: 'expected array' }, + { + code: 'unrecognized_keys', + keys: ['field', 'op', 'value'], + path: ['predicate', 'all', 0], + message: 'Unrecognized keys: "field", "op", "value"', + }, + { + code: 'invalid_value', + path: ['predicate', 'all', 0, 'op'], + message: 'Invalid option: expected one of "eq"|"ne"', + }, + { + code: 'unrecognized_keys', + keys: ['all'], + path: ['predicate'], + message: 'Unrecognized key: "all"', + }, + ]) + + expect(lines).toContain(' predicate.all.0.op: Invalid option: expected one of "eq"|"ne"') + expect(lines.join('\n')).not.toContain('Unrecognized key: "all"') + expect(lines.join('\n')).not.toContain('Unrecognized keys:') + }) + + it('keeps an unrecognized key nothing else was reported inside', () => { + // The suppression above once dropped every ancestor path, which swallowed + // this: `tll` is genuinely unknown, and the caller cannot see it anywhere + // else in the response. + const lines = formatApiErrorDetails([ + { + code: 'invalid_value', + path: ['config', 'model'], + message: 'Invalid option: expected one of "a"|"b"', + }, + { + code: 'unrecognized_keys', + keys: ['tll'], + path: ['config'], + message: 'Unrecognized key: "tll"', + }, + ]) + + expect(lines).toContain(' config: Unrecognized key: "tll"') + }) + + it('keeps a container-level cap reported alongside a bad element', () => { + // Both have to be fixed; showing only the element sends the caller back for + // a second identical 400. + const lines = formatApiErrorDetails([ + { path: ['rows'], message: 'Cannot insert more than 100 rows per batch' }, + { path: ['rows', 3, 'email'], message: 'Expected string, received number' }, + ]) + + expect(lines).toContain(' rows: Cannot insert more than 100 rows per batch') + expect(lines).toContain(' rows.3.email: Expected string, received number') + }) + + it('keeps a cross-field refusal, whose path is empty', () => { + // An empty path is an ancestor of every other path, so the blanket + // suppression erased exactly the message that names what to do. + const lines = formatApiErrorDetails([ + { path: [], message: 'Provide either filter or rowIds' }, + { path: ['workspaceId'], message: 'Required' }, + ]) + + expect(lines).toContain(' request: Provide either filter or rowIds') + expect(lines).toContain(' workspaceId: Required') + }) + + it('still shows every field of a genuine multi-field failure', () => { + const lines = formatApiErrorDetails([ + { path: ['name'], message: 'Required' }, + { path: ['workspaceId'], message: 'Required' }, + ]) + + expect(lines).toEqual([' details:', ' name: Required', ' workspaceId: Required']) + }) + + it('never suppresses the only issue there is', () => { + expect(formatApiErrorDetails([{ path: ['name'], message: 'Required' }])).toEqual([ + ' details:', + ' name: Required', + ]) + }) + it('keeps non-validation details as JSON', () => { expect(formatApiErrorDetails({ id: 'missing' })).toEqual([' details: {"id":"missing"}']) }) }) describe('raw requests', () => { - function client(options: { apiKey?: string } = { apiKey: 'key' }): SimClient { - return new SimClient({ - name: 'default', - endpoint: 'https://sim.example', - apiKey: options.apiKey ?? null, - workspaceId: 'ws_1', - output: 'json', - sources: { - endpoint: 'default', - apiKey: 'env', - workspaceId: 'env', - output: 'default', - }, - }) - } - it('returns an unconsumed response and forwards an abort signal', async () => { const fetch = vi.fn().mockResolvedValue(new Response('stream body')) vi.stubGlobal('fetch', fetch) diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index dbebfecc7d5..65f0ed120f3 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -1,3 +1,4 @@ +import chalk from 'chalk' import type { ResolvedProfile } from '../config/index' /** @@ -60,6 +61,56 @@ function buildUrl(endpoint: string, path: string, query?: Record 0 && text.length <= 200 + return new SimApiError( + `${url} returned ${kind}, not JSON (HTTP ${status}) — check your endpoint.${ + keepSnippet ? ` Response: ${truncate(text, 200)}` : '' + }`, + status + ) +} + /** * Pulls a human-readable message out of whatever the server returned. * @@ -68,16 +119,18 @@ function buildUrl(endpoint: string, path: string, query?: Record= other.length) return false + return path.every((segment, index) => segment === other[index]) +} + +/** + * True when `issue` rejects a key that another issue was found *underneath*. + * + * That combination only happens on a union: Zod reports every branch, so one bad + * operator arrives as both the real complaint (`predicate.all.0.op: invalid + * option`) and the branch with no `all` key at all (`predicate: unrecognized key + * "all"` — flatly untrue where the input landed). A genuinely unrecognized key + * is a key the schema knows nothing about, so no other issue can be reported + * inside it. + */ +function rejectsAKeyThatValidated(issue: DetailIssue, other: DetailIssue): boolean { + if (!issue.unrecognizedKeys || !isStrictPrefix(issue.path, other.path)) return false + return issue.unrecognizedKeys.includes(other.path[issue.path.length]) +} + +/** + * Drops the "unrecognized key" a union reports about the branch the input did + * not take. + * + * Scoped to that one shape on purpose. Suppressing every ancestor path instead + * swallowed complaints the caller has to act on and cannot see anywhere else: a + * container-level cap (`orderKeys: too big` alongside a bad element) and a + * cross-field refusal, whose path is empty and so is an ancestor of everything. + */ +function dropUnionBranchNoise(issues: DetailIssue[]): DetailIssue[] { + if (issues.length < 2) return issues + const kept = issues.filter( + (issue) => !issues.some((other) => rejectsAKeyThatValidated(issue, other)) + ) + return kept.length > 0 ? kept : issues +} + /** Formats nested validation issues as readable, path-aware lines. */ export function formatApiErrorDetails(details: unknown): string[] { - const issues = new Set() + const issues: DetailIssue[] = [] + const seen = new Set() const visit = (value: unknown, parentPath: string[] = []): void => { if (Array.isArray(value)) { @@ -124,15 +240,31 @@ export function formatApiErrorDetails(details: unknown): string[] { } if (typeof issue.message !== 'string' || issue.message === 'Invalid input') return - issues.add(`${path.length > 0 ? path.join('.') : 'request'}: ${issue.message}`) + const line = `${path.join('.')}: ${issue.message}` + if (seen.has(line)) return + seen.add(line) + issues.push({ + path, + message: issue.message, + unrecognizedKeys: + issue.code === 'unrecognized_keys' && Array.isArray(issue.keys) + ? issue.keys.map(String) + : null, + }) } visit(details) - if (issues.size === 0) return [` details: ${truncate(JSON.stringify(details), 1000)}`] - - const visible = [...issues].slice(0, 8) - const lines = [' details:', ...visible.map((issue) => ` ${issue}`)] - if (issues.size > visible.length) lines.push(` … ${issues.size - visible.length} more issues`) + if (issues.length === 0) return [` details: ${truncate(JSON.stringify(details), 1000)}`] + + const kept = dropUnionBranchNoise(issues) + const visible = kept.slice(0, 8) + const lines = [ + ' details:', + ...visible.map( + (issue) => ` ${issue.path.length > 0 ? issue.path.join('.') : 'request'}: ${issue.message}` + ), + ] + if (kept.length > visible.length) lines.push(` … ${kept.length - visible.length} more issues`) return lines } @@ -179,6 +311,25 @@ export class SimClient { * become the same structured `SimApiError` either way. */ async requestRaw(path: string, options: RequestOptions = {}): Promise { + return (await this.send(path, options)).response + } + + async request(path: string, options: RequestOptions = {}): Promise { + const { response, url } = await this.send(path, options) + const raw = await response.text() + + if (!raw) return undefined as T + try { + return JSON.parse(raw) as T + } catch { + throw toNonJsonError(url, response.status, response.headers.get('content-type'), raw) + } + } + + private async send( + path: string, + options: RequestOptions + ): Promise<{ response: Response; url: string }> { const apiKey = this.resolveApiKey(options.auth) const url = buildUrl(this.profile.endpoint, path, options.query) @@ -196,6 +347,7 @@ export class SimClient { }, body: hasBody ? JSON.stringify(options.body) : undefined, signal: options.signal, + redirect: 'manual', }) } catch (cause) { if (options.signal?.aborted) { @@ -207,24 +359,117 @@ export class SimClient { ) } + if (REDIRECT_STATUSES.has(response.status)) throw this.toRedirectError(url, path, response) + if (!response.ok) { const raw = await response.text() - const error = toApiError(response.status, raw) + const error = toApiError(url, response.status, response.headers.get('content-type'), raw) if (response.status === 401) { error.message = `${error.message} — run: sim login --profile ${this.profile.name}` } + if (namesWorkspaceKeyRefusal(error)) { + error.message = `${error.message} — this operation needs a personal API key: sim login --profile ${this.profile.name}` + } throw error } - return response + return { response, url } } - async request(path: string, options: RequestOptions = {}): Promise { - const response = await this.requestRaw(path, options) - const raw = await response.text() + /** + * Explains a redirect instead of following it, naming the endpoint to switch to. + * + * The destination comes from `Location` resolved against the request URL, so a + * relative target works and no string surgery is done on the configured + * endpoint. A `Location` that is missing or unparseable still has to produce a + * sentence — the redirect is the finding either way. + */ + private toRedirectError(url: string, path: string, response: Response): SimApiError { + const location = response.headers.get('location')?.trim() + let target: URL | null = null + if (location) { + try { + target = new URL(location, url) + } catch { + target = null + } + } - if (!raw) return undefined as T - return JSON.parse(raw) as T + if (!target) { + return new SimApiError( + `${url} answered HTTP ${response.status} with no usable redirect target. Check the endpoint for profile "${this.profile.name}".`, + response.status + ) + } + const suggested = redirectEndpoint(this.profile.endpoint, path, target) + if (!suggested) { + return new SimApiError( + `${url} redirected to ${target.href}. The CLI does not follow redirects, because a redirect can drop the request body and turn a write into a silent no-op.`, + response.status + ) + } + return new SimApiError( + `Endpoint redirected to ${suggested}. Run: sim configure --profile ${this.profile.name} --set-endpoint ${suggested}`, + response.status + ) + } +} + +/** + * The endpoint a redirect implies, or null when it implies no change. + * + * Strips the request's own path from the target rather than taking + * `target.origin`, so a self-hosted endpoint carrying a path prefix + * (`https://host/sim`) keeps it. Naming the bare origin would hand back a value + * that is not an API root, and following that advice would break a deployment + * that was only ever one hostname away from working. + * + * Null when the target resolves to the endpoint already configured — a + * trailing-slash or path-normalization redirect keeps the origin, and telling + * someone to set the value they already have explains nothing. + */ +export function redirectEndpoint( + endpoint: string, + requestPath: string, + target: URL +): string | null { + const prefix = target.pathname.endsWith(requestPath) + ? target.pathname.slice(0, target.pathname.length - requestPath.length) + : '' + const suggested = `${target.origin}${prefix}`.replace(/\/+$/, '') + return suggested === endpoint.replace(/\/+$/, '') ? null : suggested +} + +export interface PageProgress { + /** Call once a further page is known to be coming, with the count so far. */ + advance: (fetched: number) => void + /** Erases the line, if anything was ever written to it. */ + finish: () => void +} + +/** + * Reports cursor progress on stderr while a list keeps paging. + * + * A long cursor is many sequential requests and reads as a hang, so say so — but + * only on a terminal, and only on stderr, because stdout is what gets piped to + * `jq`. + * + * Shared because the CLI pages in two places: {@link requestAllPages} for the + * `ls` commands, and the contract-driven loop in `runtime/execute`, which also + * has to carry a cursor in the body. Only one of them had the writer, and it was + * not the one nearly every `list --limit 0` goes through. + */ +export function pageProgress(): PageProgress { + let reported = false + return { + advance: (fetched) => { + if (!process.stderr.isTTY) return + reported = true + process.stderr.write(`\r${chalk.dim(`fetched ${fetched}…`)}\u001b[K`) + }, + finish: () => { + if (reported) process.stderr.write('\r\u001b[K') + }, } } @@ -239,19 +484,29 @@ export async function requestAllPages( if (limit <= 0) return [] const items: T[] = [] + const progress = pageProgress() let cursor: string | null = null - do { - const page: V2Page = await client.request>(path, { - ...requestOptions, - query: { - ...query, - limit: Math.min(pageSize, limit - items.length), - cursor, - }, - }) - items.push(...page.data) - cursor = page.nextCursor - } while (cursor && items.length < limit) + // `finally`, because a page that throws part-way through would otherwise skip + // the cleanup and leave `fetched 1200…` sitting on the line the error is then + // written onto. + try { + do { + const page: V2Page = await client.request>(path, { + ...requestOptions, + query: { + ...query, + limit: Math.min(pageSize, limit - items.length), + cursor, + }, + }) + items.push(...page.data) + cursor = page.nextCursor + + if (cursor && items.length < limit) progress.advance(items.length) + } while (cursor && items.length < limit) + } finally { + progress.finish() + } return items.slice(0, limit) } diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index a72caa2946b..337f1ea7d25 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -180,6 +180,18 @@ describe('printRecord', () => { expect(logged[1]).toContain('alpha') }) + it('clamps a very wide value in table mode only', () => { + // A signed download URL is the whole point of the command that prints it; + // clamping it before the format branch corrupted `text`, silently. + const url = `https://example.com/${'a'.repeat(400)}` + printRecord('table', [['url', url]], {}) + printRecord('text', [['url', url]], {}) + + expect(logged[0]).toMatch(/…$/) + expect(logged[0].length).toBeLessThan(url.length) + expect(logged[1]).toBe(`url\t${url}`) + }) + it.each(['text', 'table'] as const)('sanitizes API-controlled labels in %s output', (format) => { printRecord(format, [[`${ESC}]0;pwned${BEL}safe\nlabel`, 'value']], {}) @@ -209,6 +221,10 @@ describe('formatters', () => { expect(duration(1500)).toBe('1.5s') expect(duration(90_000)).toBe('1m30s') }) + + it('rounds the high-resolution milliseconds the API reports', () => { + expect(duration(9.145596999907866)).toBe('9ms') + }) }) describe('sanitize', () => { diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 3c748b79bca..916ad57e1b9 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -109,7 +109,10 @@ export function bytes(value: number | null | undefined): string { export function duration(ms: number | null | undefined): string { if (ms === null || ms === undefined) return EMPTY - if (ms < 1000) return `${ms}ms` + // The API measures runs with a high-resolution clock, so a duration arrives as + // `9.145596999907866`. Sub-millisecond precision is noise in a terminal and + // the raw float is wider than every other cell in the row. + if (ms < 1000) return `${Math.round(ms)}ms` if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s` } @@ -184,13 +187,25 @@ function oneLine(value: string): string { */ const MAX_CELL_WIDTH = 60 -function clampCell(value: string): string { +/** + * Widest a single record field may render in `table` mode. + * + * A record prints one value per line, so a long value costs nothing but its own + * line — far more room than a table column, where one wide cell sets the width + * for every row. The clamp lives in `printRecord` rather than in the caller that + * builds the fields, so `text`, `json` and `yaml` still carry the whole value: + * clamping before the format branch truncated commands whose entire output is + * one signed URL, silently, in the format built for pipes. + */ +const MAX_RECORD_WIDTH = 160 + +function clamp(value: string, width: number): string { // ANSI-bearing cells come from the short formatters (`yes`/`no`, the empty // glyph); slicing one mid-escape would corrupt it, and none are ever wide. - if (visibleWidth(value) <= MAX_CELL_WIDTH || value !== value.replace(ANSI_PATTERN, '')) { + if (visibleWidth(value) <= width || value !== value.replace(ANSI_PATTERN, '')) { return value } - return `${value.slice(0, MAX_CELL_WIDTH - 1)}…` + return `${value.slice(0, width - 1)}…` } function renderTable(rows: T[], columns: Column[]): string { @@ -200,7 +215,9 @@ function renderTable(rows: T[], columns: Column[]): string { // remote content and gets the same treatment as a cell. Doing it here rather // than only at each call site means a future column source cannot reopen this. const headers = columns.map((column) => sanitize(column.header)) - const cells = rows.map((row) => columns.map((column) => clampCell(oneLine(column.value(row))))) + const cells = rows.map((row) => + columns.map((column) => clamp(oneLine(column.value(row)), MAX_CELL_WIDTH)) + ) const widths = columns.map((_column, index) => Math.max(visibleWidth(headers[index]), ...cells.map((line) => visibleWidth(line[index]))) ) @@ -283,7 +300,10 @@ export function printDocument(format: OutputFormat, raw: unknown): void { ) } -/** Prints a single record: machine formats from the raw value, otherwise aligned lines. */ +/** + * Prints a single record: machine formats from the raw value, otherwise aligned + * lines. As in `printList`, only the `table` rendering is clamped. + */ export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) { const machine = renderMachine(format, raw) if (machine !== null) { @@ -302,6 +322,8 @@ export function printRecord(format: OutputFormat, fields: Array<[string, string] const width = Math.max(...safeFields.map(([label]) => visibleWidth(label))) for (const [label, value] of safeFields) { - console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${oneLine(value)}`) + console.log( + `${chalk.dim(pad(`${label}:`, width + 1))} ${clamp(oneLine(value), MAX_RECORD_WIDTH)}` + ) } } diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index de9d2a939bb..e867d7c9d49 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildGeneratedCommands } from './build' +import { resetRenameWarnings } from './renamed' /** * Drives commands through commander's own parsing rather than calling @@ -743,16 +744,20 @@ describe('single-resource rendering', () => { expect(printed.join('\n')).toMatch(/email/) }) - it('truncates a nested value rather than flooding the terminal', async () => { - const printed = await lines( - ['workflows', 'get', 'wf_1'], - { id: 'wf_1', state: { blocks: 'x'.repeat(5000) } }, - 'text' - ) + it('truncates a nested value in the table, and only there', async () => { + const payload = { id: 'wf_1', state: { blocks: 'x'.repeat(5000) } } + + const table = await lines(['workflows', 'get', 'wf_1'], payload, 'table') + const clamped = table.find((line) => line.startsWith('state')) ?? '' + expect(clamped.length).toBeLessThan(300) + expect(clamped).toMatch(/…$/) - const stateLine = printed.find((line) => line.startsWith('state')) ?? '' - expect(stateLine.length).toBeLessThan(300) - expect(stateLine).toMatch(/…$/) + // `text` is the format built for pipes, so it carries the whole value: the + // clamp is a legibility cap on the human table, and clamping before the + // format branch silently truncated commands whose output is one long value. + const piped = await lines(['workflows', 'get', 'wf_1'], payload, 'text') + const whole = piped.find((line) => line.startsWith('state')) ?? '' + expect(whole).toContain('x'.repeat(5000)) }) it('emits a document command as JSON whatever the display format is', async () => { @@ -880,7 +885,7 @@ describe('contract-selected list rendering', () => { }) it('renders row matches as rows', async () => { - const printed = await lines(['tables', 'rows', 'find', 'tbl_1', '--q', 'alice'], { + const printed = await lines(['tables', 'rows', 'find', 'tbl_1', '--query', 'alice'], { matches: [{ ordinal: 3, rowId: 'row_1', column: 'email' }], truncated: false, }) @@ -1006,6 +1011,30 @@ describe('pagination slot', () => { expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' }) }) + it('says it is still fetching, on stderr, so a long cursor does not read as a hang', async () => { + // The progress writer only ever lived in `requestAllPages`, which just the + // `ls` commands use; every generated list pages through its own loop, so + // `--limit 0` sat silent through twenty sequential requests. stdout stays + // clean because that is what gets piped to `jq`. + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'a' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ data: [{ id: 'b' }], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + const terminal = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY') + Object.defineProperty(process.stderr, 'isTTY', { configurable: true, value: true }) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + try { + await program().parseAsync(['node', 'sim', 'logs', 'list', '--limit', '0']) + } finally { + if (terminal) Object.defineProperty(process.stderr, 'isTTY', terminal) + else Reflect.deleteProperty(process.stderr, 'isTTY') + } + + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain('fetched 1') + }) + it('uses a valid per-page size for unlimited and large totals', async () => { for (const requested of ['0', '250']) { mockRequest.mockReset() @@ -1164,3 +1193,113 @@ describe('bodies and fields the generator cannot flatten', () => { expect(options.query).toMatchObject({ limit: 7 }) }) }) + +describe('spellings the CLI has retired', () => { + beforeEach(() => { + resetRenameWarnings() + }) + + function warnings(): string[] { + const written: string[] = [] + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + written.push(String(chunk)) + return true + }) + return written + } + + it('still answers to a command path that moved between groups', async () => { + // `tables count create` counted rows and created nothing, so it became + // `tables rows count`. A script written against the old path predates the + // rename and has no way to know. + const written = warnings() + const [path, options] = await run( + ['tables', 'count', 'create', 'tbl_1', '--filter', '{"all":[]}'], + { data: { totalCount: 0 } } + ) + expect(path).toBe('/api/v2/tables/tbl_1/query/count') + expect(options.body).toMatchObject({ predicate: { all: [] } }) + expect(written.join('')).toContain('"sim tables count create" has been renamed') + }) + + it('still answers to a path whose group became the command itself', async () => { + // The hardest shape: `files restore create` retired in favour of `files + // restore`, so the old path needs a `create` *under* a command that now + // takes `` there. Commander matches the subcommand before the + // positional, which is what makes both spellings reachable. + const written = warnings() + const [path] = await run(['files', 'restore', 'create', 'wf_1'], { data: { id: 'wf_1' } }) + expect(path).toBe('/api/v2/files/wf_1/restore') + expect(written.join('')).toContain('"sim files restore create" has been renamed') + + const [current] = await run(['files', 'restore', 'wf_1'], { data: { id: 'wf_1' } }) + expect(current).toBe('/api/v2/files/wf_1/restore') + }) + + it('keeps retired spellings out of help', () => { + // A retired name exists for scripts, not for readers: surfacing it in help + // would teach the spelling being retired. Commander still lists a hidden + // command in `.commands`, so this asks what help itself would print. + const visible = (command: Command) => + command.commands + .filter((child) => (child as Command & { _hidden?: boolean })._hidden !== true) + .map((child) => child.name()) + + expect(visible(commandAt('tables'))).not.toContain('count') + expect(visible(commandAt('workflows', 'deployment'))).not.toContain('list') + expect(visible(commandAt('files', 'restore'))).toEqual([]) + expect( + commandAt('tables', 'rows', 'count') + .options.filter((option) => !option.hidden) + .map((option) => option.flags) + ).not.toContain('--predicate ') + }) + + it('folds a retired flag onto its current name', async () => { + const written = warnings() + const [, options] = await run(['tables', 'rows', 'find', 'tbl_1', '--q', 'needle'], { + data: { matches: [] }, + }) + expect(options.body).toMatchObject({ q: 'needle' }) + expect(written.join('')).toContain('"--q" has been renamed to "--query"') + }) + + it('refuses both spellings of one flag rather than picking a winner', async () => { + await expect( + run([ + 'tables', + 'rows', + 'count', + 'tbl_1', + '--predicate', + '{"all":[]}', + '--filter', + '{"any":[]}', + ]) + ).rejects.toThrow('--predicate is the former name of --filter; pass one, not both') + }) + + it('still requires a renamed-but-required field, naming its current spelling', async () => { + // The current flag cannot be commander-mandatory or the retired spelling + // would be rejected before it could be folded, so the requirement is raised + // downstream instead. It must still be raised. + await expect(run(['tables', 'rows', 'find', 'tbl_1'])).rejects.toThrow('--query is required') + }) + + it('never lets a retired path shadow a live command', () => { + const seen = new Map() + const walk = (command: Command, prefix: string[]) => { + for (const child of command.commands) { + const path = [...prefix, child.name()].join(' ') + const hidden = (child as Command & { _hidden?: boolean })._hidden === true + // Commander resolves a duplicate name to whichever was registered + // first, so a retired path sharing a live command's name would make the + // live one unreachable. + expect(seen.has(path) && !hidden).toBe(false) + seen.set(path, hidden) + walk(child, [...prefix, child.name()]) + } + } + walk(program(), []) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 0ffb597f79c..958ccce154e 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -5,6 +5,7 @@ import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { deriveCommandPath } from './derive' import { executeOperation } from './execute' import { addOperationOptions } from './options' +import { warnRenamedCommand } from './renamed' import { flagNameFor, flagSpecFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD } from './request' import type { OperationSpec } from './types' @@ -155,6 +156,41 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri return addMissingArgumentExample(configureOperation(new Command(leafName), operation, spec)) } +/** + * Registers a command at a path it used to have, hidden and warning on use. + * + * The leaf is built by the same `buildLeaf` the current spelling uses, so a + * renamed command cannot drift from the one it forwards to — there is one + * definition and two ways to reach it. + * + * Commander resolves a subcommand before it fills a positional, so a rename + * that turned a group into a leaf (`files restore create` into `files restore`) + * still parses: `create` is matched as the hidden subcommand rather than being + * read as the file id. The cost is that a resource whose id is literally + * `create` cannot be addressed through the current spelling, which no id + * generated by `@sim/utils/id` ever is. + */ +function addRenamedCommand( + groups: Map, + operation: V2OperationName, + spec: CommandSpec, + from: string, + to: string +): void { + const segments = from.split(' ') + const [groupName, ...rest] = segments + if (rest.length === 0) throw new Error(`${operation}.renamedFrom "${from}" must include a verb`) + + let parent = groupFor(groups, groupName) + for (const segment of rest.slice(0, -1)) { + parent = nestedGroup(parent, segment, { hidden: true }) + } + + const leaf = buildLeaf(operation, spec, rest[rest.length - 1]) + leaf.hook('preAction', () => warnRenamedCommand(from, to)) + parent.addCommand(leaf, { hidden: true }) +} + function groupFor(groups: Map, name: string): Command { const existing = groups.get(name) if (existing) return existing @@ -171,14 +207,20 @@ function resourceLabel(name: string): string { return label.replaceAll('-', ' ') } -function nestedGroup(parent: Command, name: string): Command { +/** + * `hidden` applies only when this call is what creates the group. A rename that + * reaches through a group the current surface also uses (`tables rows`) must + * leave it in help; only a group resurrected solely to host a renamed leaf + * (`tables count`) stays hidden. + */ +function nestedGroup(parent: Command, name: string, options: { hidden?: boolean } = {}): Command { const existing = parent.commands.find((candidate) => candidate.name() === name) if (existing) return existing const created = new Command(name).description( `Manage ${resourceLabel(parent.name())} ${name.replaceAll('-', ' ')}` ) - parent.addCommand(created) + parent.addCommand(created, { hidden: options.hidden }) return created } @@ -217,6 +259,12 @@ function variantCommandSpec(spec: CommandSpec, variant: CommandVariantSpec): Com /** Builds every JSON command described by the generated operation table. */ export function buildGeneratedCommands(): Command[] { const groups = new Map() + const renamed: Array<{ + operation: V2OperationName + spec: CommandSpec + from: string + to: string + }> = [] for (const operation of Object.keys(V2_OPERATIONS) as V2OperationName[]) { const spec = CLI_CONTRACT[operation] ?? {} @@ -247,6 +295,18 @@ export function buildGeneratedCommands(): Command[] { variant.command.split(' ') ) } + + for (const from of spec.renamedFrom ?? []) { + renamed.push({ operation, spec, from, to: segments.join(' ') }) + } + } + + // Second pass on purpose. Commander resolves a duplicate name to whichever + // was registered first, so registering every current spelling before any + // renamed one makes it impossible for a retired path to shadow a live command + // that happens to reuse its name. + for (const { operation, spec, from, to } of renamed) { + addRenamedCommand(groups, operation, spec, from, to) } return [...groups.values()].sort((a, b) => a.name().localeCompare(b.name())) diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 5b3ebd83e70..e265cc3028d 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -2,9 +2,10 @@ import type { Command } from 'commander' import { clientFrom } from '../context' import type { CommandSpec } from '../contract/types' import type { V2OperationName } from '../generated/v2-api' -import { SimApiError, type V2Page } from '../http/client' +import { pageProgress, SimApiError, type V2Page } from '../http/client' import { camel } from './derive' import { DEFAULT_LIMIT } from './options' +import { warnRenamedFlag } from './renamed' import { buildRequest, flagNameFor, @@ -20,6 +21,41 @@ function cursorSlot(operationSpec: OperationSpec): 'query' | 'body' | null { return null } +/** + * Moves a value supplied under a flag's former name onto its current one. + * + * Done here rather than in `buildRequest` because this is where the parsed + * flags are assembled and still keyed by what the caller typed; by the time the + * request is built, only the current spelling has meaning. + * + * Supplying both spellings is refused rather than resolved. They are the same + * field, so a caller who sets both has two different values in mind and no + * reading of "the new one wins" is more likely to be the intended one. + */ +function foldRenamedFlags( + operation: V2OperationName, + commandSpec: CommandSpec, + flags: Record +): void { + for (const [field, flag] of Object.entries(commandSpec.flags ?? {})) { + if (!flag.renamedFrom?.length) continue + + const current = flagNameFor(operation, field) + for (const previous of flag.renamedFrom) { + const supplied = flags[camel(previous)] + if (supplied === undefined) continue + if (flags[camel(current)] !== undefined) { + throw new SimApiError( + `--${previous} is the former name of --${current}; pass one, not both`, + 0 + ) + } + warnRenamedFlag(previous, current) + flags[camel(current)] = supplied + } + } +} + /** Executes a parsed generated command, including cursor pagination. */ export async function executeOperation( operation: V2OperationName, @@ -45,6 +81,8 @@ export async function executeOperation( requestFlags[camel(flagNameFor(operation, field))] = invocation[pathPositionalCount + index] } + foldRenamedFlags(operation, commandSpec, requestFlags) + if (commandSpec.confirm && !requestFlags.yes) { throw new SimApiError(`${commandSpec.confirm} Re-run with --yes to confirm.`, 0) } @@ -77,21 +115,28 @@ export async function executeOperation( const pageSize = Math.min(Number.isFinite(limit) ? limit : DEFAULT_LIMIT, DEFAULT_LIMIT) const pageLimit = 'limit' in (operationSpec[paging] ?? {}) ? { limit: pageSize } : {} const rows: unknown[] = [] + const progress = pageProgress() let cursor: string | null = null - do { - const page: V2Page = await client.request(request.path, { - method: operationSpec.method, - query: paging === 'query' ? { ...request.query, ...pageLimit, cursor } : request.query, - body: - paging === 'body' - ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } - : request.body, - }) - rows.push(...page.data) - cursor = page.nextCursor - } while (cursor && rows.length < limit) - + // `finally`, for the same reason as `requestAllPages`: a page that throws + // would otherwise leave the progress text on the line the error prints onto. + try { + do { + const page: V2Page = await client.request(request.path, { + method: operationSpec.method, + query: paging === 'query' ? { ...request.query, ...pageLimit, cursor } : request.query, + body: + paging === 'body' + ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } + : request.body, + }) + rows.push(...page.data) + cursor = page.nextCursor + if (cursor && rows.length < limit) progress.advance(rows.length) + } while (cursor && rows.length < limit) + } finally { + progress.finish() + } renderPage(profile.output, Number.isFinite(limit) ? rows.slice(0, limit) : rows, commandSpec) return } diff --git a/packages/sim-cli/src/runtime/options.test.ts b/packages/sim-cli/src/runtime/options.test.ts new file mode 100644 index 00000000000..83831c915ad --- /dev/null +++ b/packages/sim-cli/src/runtime/options.test.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment node + */ +import { Command } from 'commander' +import { describe, expect, it } from 'vitest' +import { addOperationOptions } from './options' +import type { OperationSpec } from './types' + +const DELETE_TABLE: OperationSpec = { + method: 'DELETE', + path: '/api/v2/tables/{tableId}', + pathParams: ['tableId'], +} + +function confirmHelp(): string { + const command = new Command('delete') + addOperationOptions( + command, + 'deleteTable', + { confirm: 'This deletes the table and all of its rows.' }, + DELETE_TABLE + ) + return command.helpInformation() +} + +describe('the --yes flag on a destructive command', () => { + /** + * `executeOperation` throws unless `--yes` is present, whether or not stdin is + * a terminal — nothing anywhere prompts. Advertising a confirmation to skip + * described a question the CLI never asks. + */ + it('describes itself as the confirmation, not as skipping one', () => { + const help = confirmHelp() + expect(help).toMatch(/-y, --yes\s+Confirm this destructive operation \(required\)/) + expect(help).not.toMatch(/skip/i) + expect(help).not.toMatch(/prompt/i) + }) +}) diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 6fa18ea6ae7..54081cd0b4e 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -93,13 +93,25 @@ function addFieldOption( : '' }${descriptor.required ? ' (required)' : ''}` + const renamedFrom = flag.renamedFrom ?? [] const option = new Option(`${short}--${name} ${placeholder}`, describe) if (choices && !takesList) option.choices([...choices]) if (descriptor.default !== undefined && field !== 'limit') { option.default(undefined, String(descriptor.default)) } - if (descriptor.required) option.makeOptionMandatory() + // Commander's mandatory check runs before `executeOperation` can fold a + // renamed spelling onto the current one, so a required field that has been + // renamed would reject the very argv this exists to keep working. The + // requirement is not lost: `buildRequest` raises it against the current + // spelling once both have had their chance to supply the value. + if (descriptor.required && renamedFrom.length === 0) option.makeOptionMandatory() command.addOption(option) + + for (const previous of renamedFrom) { + const retired = new Option(`--${previous} ${placeholder}`).hideHelp() + if (choices && !takesList) retired.choices([...choices]) + command.addOption(retired) + } } /** Adds request-field and safety options for one generated operation. */ @@ -162,6 +174,10 @@ export function addOperationOptions( } if (commandSpec.confirm) { - command.option('-y, --yes', 'Skip the confirmation') + // There is no prompt to skip: a `confirm` command refuses outright when the + // flag is absent, in a TTY or not. Calling it "Skip the confirmation" sent + // readers looking for a question the CLI never asks, and hid that the flag + // is the only way the command ever runs. + command.option('-y, --yes', 'Confirm this destructive operation (required)') } } diff --git a/packages/sim-cli/src/runtime/renamed.ts b/packages/sim-cli/src/runtime/renamed.ts new file mode 100644 index 00000000000..6eabc7865b3 --- /dev/null +++ b/packages/sim-cli/src/runtime/renamed.ts @@ -0,0 +1,41 @@ +/** + * Support for spellings the CLI has moved on from. + * + * A rename is not an alias. {@link CommandSpec.aliases} are ergonomic shorthands + * — `ls`, `mv` — that the CLI wants people to use, so they appear in help. A + * renamed spelling is kept only so a script written against the old name keeps + * working: it stays out of help and out of the generated docs, and says once, + * on stderr, what to write instead. + * + * Warnings go to stderr rather than stdout because the old name is most likely + * to survive inside exactly the kind of script that pipes stdout into `jq`, and + * a deprecation notice in the middle of a JSON document is a worse bug than the + * one it reports. + */ + +/** Reported spellings, so a loop over many rows warns once rather than per row. */ +const warned = new Set() + +function warn(kind: string, from: string, to: string): void { + const key = `${kind}:${from}` + if (warned.has(key)) return + warned.add(key) + process.stderr.write( + `warning: ${kind} "${from}" has been renamed to "${to}". The old name still works.\n` + ) +} + +/** Announces a command path that has been renamed, naming its current spelling. */ +export function warnRenamedCommand(from: string, to: string): void { + warn('command', `sim ${from}`, `sim ${to}`) +} + +/** Announces a flag that has been renamed, naming its current spelling. */ +export function warnRenamedFlag(from: string, to: string): void { + warn('flag', `--${from}`, `--${to}`) +} + +/** Test seam: renames warn once per process, and each test needs a clean slate. */ +export function resetRenameWarnings(): void { + warned.clear() +} diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index dc8295e70d6..f419c078af6 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -47,11 +47,24 @@ describe('buildRequest', () => { }) it('omits absent optional fields so the server applies its own default', () => { + // Except where the contract asks for one, as `details` does below. const built = buildRequest('listLogs', [], {}, WORKSPACE) - expect(built.query).toEqual({ workspaceId: WORKSPACE }) + expect(built.query).toEqual({ workspaceId: WORKSPACE, details: 'full' }) expect(built.query).not.toHaveProperty('order') }) + it('asks for the detail level its own declared columns read from', () => { + // `logs list` renders `workflow.name`, which the API sends only at `full`, + // so the default request left the workflow column empty on every row. + const built = buildRequest('listLogs', [], {}, WORKSPACE) + expect(built.query.details).toBe('full') + }) + + it('lets an explicit detail level override the contract default', () => { + const built = buildRequest('listLogs', [], { details: 'basic' }, WORKSPACE) + expect(built.query.details).toBe('basic') + }) + it('never sends a field the contract marked omit', () => { // `stream` would switch the response to SSE, which the JSON client cannot read. const built = buildRequest('executeWorkflow', ['wf_1'], { stream: true }, WORKSPACE) @@ -253,3 +266,79 @@ describe('JSON flags that name a file', () => { expect(() => coerce('{"a":', field, {}, 'workflow')).not.toThrow(/@path/) }) }) + +describe('folder paths are typed by the name the app shows', () => { + it('encodes a space so the visible folder name is what the caller types', () => { + const built = buildRequest('listWorkflows', [], { folder: '/Folder 1' }, WORKSPACE) + expect(built.query.folderPath).toBe('/Folder%201') + }) + + it('leaves an already-encoded path alone, because that is the form it prints', () => { + // `workflows ls` prints the wire form in its `ref` column and the README + // uses it, so the value people paste back must not become `%2520`. + const built = buildRequest('listWorkflows', [], { folder: '/Folder%201' }, WORKSPACE) + expect(built.query.folderPath).toBe('/Folder%201') + }) + + it('encodes each segment and keeps the separators between them', () => { + const built = buildRequest('listTables', [], { folder: '/cli-test-a/nested one' }, WORKSPACE) + expect(built.query.folderPath).toBe('/cli-test-a/nested%20one') + }) + + it('still treats the leading slash as optional', () => { + const built = buildRequest('createTableFolder', [], { path: 'cli-test-noslash' }, WORKSPACE) + expect(built.body).toMatchObject({ path: 'cli-test-noslash' }) + }) + + it('escapes the characters encodeURIComponent leaves raw', () => { + // The route re-encodes each segment and demands a byte-for-byte match, and + // `encodeURIComponent` alone leaves `!'()*` alone — so `/Q1 (draft)` went + // out as `/Q1%20(draft)` and came back "Path must be a canonical folder + // path". Folder names like these are ordinary. + const built = buildRequest('createTableFolder', [], { path: "/Q1 (draft)/Sam's !*" }, WORKSPACE) + expect(built.body).toMatchObject({ path: '/Q1%20%28draft%29/Sam%27s%20%21%2A' }) + }) + + it('spells out a dot segment, which the API refuses to read as a relative path', () => { + const built = buildRequest('createTableFolder', [], { path: '/./..' }, WORKSPACE) + expect(built.body).toMatchObject({ path: '/%2E/%2E%2E' }) + }) + + it('leaves the canonical form it prints unchanged when pasted back', () => { + // Every one of these is what the CLI's own `ref` column shows, so it is what + // people paste into the next command; re-encoding it must be a no-op. + for (const name of ['Q1 (draft)', "Sam's stuff", 'wow!', 'a*b', '.', '..', '50% off']) { + const canonical = buildRequest('createTableFolder', [], { path: `/${name}` }, WORKSPACE).body + ?.path as string + const again = buildRequest('createTableFolder', [], { path: canonical }, WORKSPACE) + expect(again.body).toMatchObject({ path: canonical }) + } + }) + + it('encodes a literal percent that is not an escape', () => { + const built = buildRequest('createTableFolder', [], { path: '/50% off' }, WORKSPACE) + expect(built.body).toMatchObject({ path: '/50%25%20off' }) + }) + + it('encodes both ends of a folder move', () => { + const built = buildRequest( + 'relocateTableFolder', + [], + { path: '/old name', destination: '/new name' }, + WORKSPACE + ) + expect(built.body).toMatchObject({ path: '/old%20name', destinationPath: '/new%20name' }) + }) + + it('encodes every value of the repeatable folder filter before joining them', () => { + const built = buildRequest('listLogs', [], { folder: ['/a b', '/c'] }, WORKSPACE) + expect(built.query.folderPaths).toBe('/a%20b,/c') + }) + + it('leaves a field the contract has not marked untouched', () => { + // `files upload` and `knowledge documents upload` take a LOCAL path; the + // marker is what keeps the encoder away from one. + const local = './My Docs/report.pdf' + expect(coerce(local, { kind: 'string' }, {}, 'file')).toBe(local) + }) +}) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index f30ff7e3f35..bb15831c9e0 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -156,6 +156,64 @@ function readListValues(raw: unknown, flagName: string): string[] { }) } +/** A percent-escape the caller has already applied, well-formed enough to decode. */ +const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/ + +/** What `encodeURIComponent` leaves raw and the server's canonical encoder does not. */ +const SUB_DELIMITERS = /[!'()*]/g + +/** + * Encodes one segment exactly as `encodeFolderPathSegment` does server-side. + * + * The route does not merely decode a path, it re-encodes each segment and + * demands the result match byte for byte, so "close enough" is rejected outright + * with `Path must be a canonical folder path`. `encodeURIComponent` alone leaves + * `!'()*` raw — common in real folder names (`Q1 (draft)`, `Sam's stuff`) — and + * spells a lone `.` or `..` as itself, which the server refuses to let address a + * folder actually named that. + */ +function encodeFolderPathSegment(name: string): string { + if (name === '.') return '%2E' + if (name === '..') return '%2E%2E' + return encodeURIComponent(name).replace( + SUB_DELIMITERS, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) +} + +/** + * Rewrites one folder path into the API's canonical wire form. + * + * The wire form encodes each segment, so `/Folder 1` in the app is + * `/Folder%201` to the API — and typing the name you can see was rejected with + * a message that never said the word encoding. Splitting on `/` first is what + * keeps the separators: `encodeURIComponent` over the whole path would turn + * every one of them into `%2F` and address a single top-level folder whose name + * contains slashes. + * + * Decoding each segment before encoding it is what makes this idempotent, and + * it has to be: the encoded spelling is what the CLI prints today, what the + * README shows, and therefore what people will paste back. `/Folder 1` and + * `/Folder%201` must reach the same folder, and `%2520` is the failure to + * avoid. The limit of that rule is a folder whose name really contains a `%` + * followed by two hex digits — `100%20off` reads as `100 off`. A stray `%` is + * safe, because it fails to decode and is encoded literally, and the ambiguous + * name can always be typed in its encoded form (`100%2520off`). + */ +export function encodeFolderPath(value: string): string { + return value + .split('/') + .map((segment) => { + if (!PERCENT_ESCAPE.test(segment)) return encodeFolderPathSegment(segment) + try { + return encodeFolderPathSegment(decodeURIComponent(segment)) + } catch { + return encodeFolderPathSegment(segment) + } + }) + .join('/') +} + /** * Points at `@` when a value that failed to parse looks like a filename. * @@ -192,7 +250,12 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: * or failed validation outright. */ if (flag.list) { - const values = readListValues(raw, flagName) + const values = readListValues(raw, flagName).map((value) => + flag.folderPath ? encodeFolderPath(value) : value + ) + // Encoding first is also what keeps the comma-joined form unambiguous: a + // folder name containing a comma leaves here as `%2C`, so the route's split + // cannot cut one path in half. return field.kind === 'string' ? values.join(',') : values } @@ -222,6 +285,8 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: throw new SimApiError(`--${flagName} must be one of: ${choices.join(', ')}`, 0) } + if (flag.folderPath && typeof raw === 'string') return encodeFolderPath(raw) + return raw } @@ -311,12 +376,16 @@ export function buildRequest( // Commander stores `--min-duration-ms` as `minDurationMs`; reading by the // flag's own name silently finds nothing. const omitProfileWorkspace = commandSpec.allWorkspaces && flags.allWorkspaces === true - const raw = + const provided = field === PROFILE_INJECTED_FIELD ? omitProfileWorkspace ? undefined : workspaceId : flags[camel(flagName)] + // A contract default only applies to what the caller left unsaid, so + // typing the flag — including typing the server's own default back — still + // decides. It is validated like any other value, enum choices included. + const raw = provided ?? flag.requestDefault const value = coerce(raw ?? undefined, descriptor, flag, flagName) if (value === undefined) { diff --git a/packages/sim-cli/src/runtime/result.test.ts b/packages/sim-cli/src/runtime/result.test.ts new file mode 100644 index 00000000000..9bb79216577 --- /dev/null +++ b/packages/sim-cli/src/runtime/result.test.ts @@ -0,0 +1,222 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLI_CONTRACT } from '../contract/commands' +import type { CommandSpec } from '../contract/types' +import { renderPage, renderResult } from './result' + +let logged: string[] + +beforeEach(() => { + logged = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + logged.push(line) + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +/** The table arrives as one string; its first line is the header. */ +function tableLines(): string[] { + return logged.join('\n').split('\n') +} + +describe('single-record output is only clamped for the human table', () => { + const url = `https://sim-storage.example.com/exports/probe.csv?X-Amz-Signature=${'a'.repeat(300)}` + + it('keeps the whole value in text, which exists to be piped', () => { + renderResult('tableExportDownload', 'text', { url }, {}) + expect(logged[0]).toBe(`url\t${url}`) + }) + + it('keeps the whole value in json', () => { + renderResult('tableExportDownload', 'json', { url }, {}) + expect(JSON.parse(logged[0])).toEqual({ url }) + }) + + it('clamps the value in table mode', () => { + renderResult('tableExportDownload', 'table', { url }, {}) + expect(logged[0]).toMatch(/…$/) + expect(logged[0].length).toBeLessThan(url.length) + }) +}) + +describe('inferred cells pick a format from the key shape', () => { + const row = { + createdAt: '2026-08-17T20:35:38.478Z', + durationMs: 9.145596999907866, + size: 3000000, + isActive: true, + deletedAt: null, + rowCount: 0, + displayName: 'probe', + } + + it('formats timestamps, durations, byte counts and booleans in a record', () => { + renderResult('getTable', 'text', row, {}) + expect(logged).toEqual([ + 'created at\t2026-08-17 20:35:38', + 'duration\t9ms', + 'size\t2.9 MB', + 'active\tyes', + 'deleted at\t', + 'row count\t0', + 'display name\tprobe', + ]) + }) + + it('de-camelCases inferred table headers', () => { + renderPage('table', [row], {}) + expect(tableLines()[0].split(/\s{2,}/)).toEqual([ + 'CREATED AT', + 'DURATION', + 'SIZE', + 'ACTIVE', + 'DELETED AT', + 'ROW COUNT', + 'DISPLAY NAME', + ]) + }) + + it('leaves json and yaml on the raw payload', () => { + renderResult('getTable', 'json', row, {}) + renderResult('getTable', 'yaml', row, {}) + expect(JSON.parse(logged[0])).toEqual(row) + expect(logged[1]).toContain('durationMs: 9.145596999907866') + }) + + it('infers nothing when the value type disagrees with the key', () => { + renderResult('getTable', 'text', { size: 'small', createdAt: 'whenever', isActive: 'yes' }, {}) + expect(logged).toEqual(['size\tsmall', 'created at\twhenever', 'is active\tyes']) + }) + + it('rounds a relevance score to a readable precision', () => { + renderResult('searchKnowledge', 'text', { similarity: 0.2818676545790171 }, {}) + expect(logged[0]).toBe('similarity\t0.2819') + }) + + it('leaves explicit column formats alone', () => { + const spec: CommandSpec = { + columns: [{ header: 'size', format: 'auto' }], + } + renderPage('text', [{ size: 3000000 }], spec) + expect(logged[0]).toBe('3000000') + }) +}) + +describe('cells the user named, not the API', () => { + // `tables rows list` and `tables rows query` expand `data`, whose keys are + // whatever the caller called their columns. A key shape is a promise about + // the value, and only the API's own field names carry one. + const rows = [{ id: 'row_1', data: { score: 3, size: 5, duration: 30, isBillable: true } }] + const spec: CommandSpec = { expand: 'data' } + + it('leaves a user column named like an API field alone', () => { + renderPage('text', rows, spec) + expect(logged[0]).toBe('row_1\t3\t5\t30\ttrue') + }) + + it('heads each one with the name the user has to type back into --filter', () => { + renderPage('table', rows, spec) + expect(tableLines()[0].split(/\s{2,}/)).toEqual([ + 'ID', + 'SCORE', + 'SIZE', + 'DURATION', + 'ISBILLABLE', + ]) + }) +}) + +describe('a folder path the operation declared no column for', () => { + it('is decoded in the record the create echoes back', () => { + // `tables folders create 'Reports/Q1 2026'` answered `/Reports/Q1%202026` + // while the `ls` right after it showed the same folder decoded. + renderResult( + 'createTableFolder', + 'text', + { name: 'Q1 2026', path: '/Reports/Q1%202026', parentPath: '/Reports' }, + {} + ) + expect(logged).toContain('path\t/Reports/Q1 2026') + }) + + it('stays in wire form in json, which is what gets fed back', () => { + renderResult('createTableFolder', 'json', { path: '/Reports/Q1%202026' }, {}) + expect(JSON.parse(logged[0])).toEqual({ path: '/Reports/Q1%202026' }) + }) +}) + +describe('a declared field that the API stops returning', () => { + const spec: CommandSpec = { + fields: [ + { header: 'plan' }, + { header: 'credits used', path: 'credits.used' }, + { header: 'credits limit', path: 'credits.limit' }, + ], + } + + it('is reported as absent rather than dropped', () => { + renderResult('getBillingStatus', 'table', { plan: 'team' }, spec) + expect(logged).toHaveLength(3) + expect(logged[1]).toContain('credits used') + expect(logged[2]).toContain('credits limit') + }) + + it('stays an empty field in text, so cut -f2 still lines up', () => { + renderResult('getBillingStatus', 'text', { plan: 'team' }, spec) + expect(logged).toEqual(['plan\tteam', 'credits used\t', 'credits limit\t']) + }) +}) + +describe('folder paths are shown by name, but piped in wire form', () => { + const folders = [ + { + path: '/cli-test-a/nested%20one', + name: 'nested one', + parentPath: '/cli-test-a', + updatedAt: '2026-08-17T20:35:38.478Z', + }, + ] + const spec = CLI_CONTRACT.listTableFolders as CommandSpec + + it('decodes the path in the table, which held it next to the decoded name', () => { + renderPage('table', folders, spec) + const [, row] = tableLines() + expect(row).toContain('/cli-test-a/nested one') + expect(row).not.toContain('%20') + }) + + it('decodes the path in text, the format shell plumbing reads', () => { + renderPage('text', folders, spec) + expect(logged[0].split('\t')[0]).toBe('/cli-test-a/nested one') + }) + + it('keeps the wire form in json, so a path fed back still resolves', () => { + renderPage('json', folders, spec) + expect(JSON.parse(logged[0])[0].path).toBe('/cli-test-a/nested%20one') + }) + + it('keeps the wire form in yaml for the same reason', () => { + renderPage('yaml', folders, spec) + expect(logged[0]).toContain('/cli-test-a/nested%20one') + }) + + it('decodes a declared record field too', () => { + renderResult( + 'getFile', + 'text', + { id: 'f_1', folderPath: '/cli-test-a/nested%20one' }, + CLI_CONTRACT.getFile as CommandSpec + ) + expect(logged).toContain('folder\t/cli-test-a/nested one') + }) + + it('shows an undecodable path as it arrived rather than dropping it', () => { + renderPage('text', [{ path: '/100%zz', name: 'x', parentPath: '/', updatedAt: null }], spec) + expect(logged[0].split('\t')[0]).toBe('/100%zz') + }) +}) diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index e803f45934d..664a06d1ba9 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -38,6 +38,32 @@ function at(row: unknown, path: string): unknown { ) } +/** + * Undoes the wire encoding of a folder path for the human formats. + * + * The inverse of `encodeFolderPath`, per segment for the same reason: `%2F` is + * a slash inside one folder's name, not a separator. A segment that fails to + * decode is shown as it arrived rather than dropped — the point is to show the + * name, and a malformed one is still the truth about what the server holds. + * + * Callers must reach this only from a `table` or `text` rendering path — the + * hand-written `ls` builds its own columns and so decodes through here directly. + * `json` and `yaml` render from the raw payload so that switching format never + * changes the data, and a script piping a path back needs the wire form. + */ +export function decodeFolderPath(value: string): string { + return value + .split('/') + .map((segment) => { + try { + return decodeURIComponent(segment) + } catch { + return segment + } + }) + .join('/') +} + function renderCell( value: unknown, format: ColumnSpec['format'], @@ -56,6 +82,8 @@ function renderCell( return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) case 'count': return Array.isArray(value) ? String(value.length) : text(null) + case 'folder-path': + return typeof value === 'string' ? text(decodeFolderPath(value)) : text(value) case 'trace-count': { const count = countTraceSpans(value) return `${count} ${count === 1 ? 'span' : 'spans'}${ @@ -68,11 +96,95 @@ function renderCell( } } -const NESTED_CELL_WIDTH = 160 +/** ISO timestamps: `createdAt`, `updatedAt`, `expiresAt`, `startDate`. */ +const TIMESTAMP_KEY = /(?:At|Date)$/ +/** Millisecond durations: `durationMs`, `totalDurationMs`, `duration`. */ +const DURATION_KEY = /Ms$|^duration/ +/** Byte counts: `size`, `fileSize`, `usageBytes`. */ +const BYTES_KEY = /^size$|(?:Size|Bytes)$/ +/** Yes/no facts: `isActive`, `hasServiceAccountKey`. */ +const BOOL_KEY = /^(?:is|has)[A-Z]/ +/** Relevance scores in 0–1: `similarity`, `score`, `matchScore`. */ +const RATIO_KEY = /^(?:similarity|score)$|(?:Similarity|Score)$/ +/** + * Wire-encoded folder paths, the only `*Path` keys the v2 responses carry. + * + * The folder create, move and delete operations declare no columns, so their + * echo of the path fell through to the raw wire form — `sim tables folders + * create 'Reports/Q1 2026'` answered `/Reports/Q1%202026` and the `ls` right + * after it showed the same folder decoded. + */ +const FOLDER_PATH_KEY = /^(?:path|parentPath|folderPath)$/ + +/** Enough of an ISO stamp to be sure a string is one before parsing it as a date. */ +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/ + +/** Decimals kept for a ratio; `0.2818676545790171` is noise past the fourth. */ +const RATIO_PRECISION = 4 + +/** + * Picks a renderer for a value the contract says nothing about, from the shape + * of its key. + * + * Most operations declare no `columns`/`fields`, so their output fell through to + * `String(value)` and printed raw ISO stamps, raw byte counts and raw float + * milliseconds next to sibling commands that format all three. The runtime type + * has to agree with the key before anything is inferred — a `size` that is a + * string is not a byte count, a `deletedAt` of `null` is not a date — so a + * mismatch falls back to the plain stringification rather than to `NaN`. + * + * Only ever asked about a key the API itself named. A key shape is a promise + * about the value, and only the contract's own field names carry one. + */ +function inferFormat(key: string, value: unknown): ColumnSpec['format'] | null { + if (typeof value === 'boolean') return BOOL_KEY.test(key) ? 'bool' : null + if (typeof value === 'string') { + if (FOLDER_PATH_KEY.test(key)) return 'folder-path' + return TIMESTAMP_KEY.test(key) && ISO_TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value)) + ? 'timestamp' + : null + } + if (typeof value !== 'number' || !Number.isFinite(value)) return null + if (DURATION_KEY.test(key)) return 'duration' + if (BYTES_KEY.test(key)) return 'bytes' + return null +} + +function inferredCell(key: string, value: unknown): string { + if (typeof value === 'number' && Number.isFinite(value) && RATIO_KEY.test(key)) { + return value.toFixed(RATIO_PRECISION) + } + return renderCell(value, inferFormat(key, value) ?? 'auto') +} + +/** `latestOperationStatus` → `latest operation status`, `row_count` → `row count`. */ +function humanizeKey(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase() +} -function recordCell(value: unknown): string { - const rendered = renderCell(value, 'auto') - return rendered.length > NESTED_CELL_WIDTH ? `${rendered.slice(0, NESTED_CELL_WIDTH)}…` : rendered +/** + * Header for an inferred column or field. + * + * The raw key reached the terminal as `DISPLAYNAME` and `LATESTOPERATIONSTATUS` + * once the table upper-cased it. A unit suffix goes too when the value's + * formatter already prints the unit (`durationMs` heading a `9ms`), and so does + * the `is` of a boolean, which the yes/no value makes redundant. `has` stays: + * `has key` says something that `key` alone does not. + */ +function inferHeader(key: string, format: ColumnSpec['format'] | null): string { + const trimmed = + format === 'duration' || format === 'bytes' + ? key.replace(/(?:Ms|Bytes)$/, '') + : format === 'bool' + ? key.replace(/^is(?=[A-Z])/, '') + : key + return humanizeKey(trimmed || key) } function columnsFrom(specs: ColumnSpec[]): Column[] { @@ -87,14 +199,29 @@ function fieldsFrom( specs: ColumnSpec[], options: RenderResultOptions = {} ): Array<[string, string]> { - return specs.flatMap((spec) => { + return specs.map<[string, string]>((spec) => { const value = at(data, spec.path ?? spec.header) - return value === undefined ? [] : [[spec.header, renderCell(value, spec.format, options)]] + // A declared field is editorial: someone decided this record is not fully + // described without it. Dropping it when the API stops returning it made + // `billing status` print no credits at all and say nothing about it, so an + // absent field shows the same glyph a null one does. + return [spec.header, value === undefined ? text(null) : renderCell(value, spec.format, options)] }) } +/** + * Builds columns for a list the contract declares none for. + * + * A row's own keys are the API's, so their shape may be read as a promise about + * the value. The keys inside `expand` are not: `tables rows list` and `tables + * rows query` expand `data`, whose keys are the column names the *user* chose. + * Inferring there renamed their columns (`isBillable` heading as `BILLABLE`, + * which is no longer the string `--filter` wants back) and reformatted their + * values (a `score` of 3 as `3.0000`, a `size` of 5 as `5 B`). So an expanded + * cell keeps its literal key and its plain stringification. + */ function inferColumns(rows: unknown[], expand?: string): Column[] { - const paths: Array<{ path: string; header: string }> = [] + const paths: Array<{ path: string; key: string; header: string; owned: boolean }> = [] const seen = new Set() for (const row of rows) { @@ -103,7 +230,7 @@ function inferColumns(rows: unknown[], expand?: string): Column[] { if (seen.has(key)) continue if (value !== null && typeof value === 'object') continue seen.add(key) - paths.push({ path: key, header: key }) + paths.push({ path: key, key, header: inferHeader(key, inferFormat(key, value)), owned: true }) } } @@ -115,14 +242,24 @@ function inferColumns(rows: unknown[], expand?: string): Column[] { for (const key of Object.keys(container)) { if (nested.has(key)) continue nested.add(key) - paths.push({ path: `${expand}.${key}`, header: seen.has(key) ? `${expand}.${key}` : key }) + // The header keeps the container prefix only where the bare key would + // collide with one of the row's own columns. + paths.push({ + path: `${expand}.${key}`, + key, + header: seen.has(key) ? `${expand}.${key}` : key, + owned: false, + }) } } } - return paths.map(({ path, header }) => ({ + return paths.map(({ path, key, header, owned }) => ({ header: sanitize(header), - value: (row: unknown) => renderCell(at(row, path), 'auto'), + // The format is re-inferred per row: the first row decided the header, but a + // later row may hold a different type under the same key. + value: (row: unknown) => + owned ? inferredCell(key, at(row, path)) : renderCell(at(row, path), 'auto'), })) } @@ -182,7 +319,10 @@ export function renderResult( const fields = spec.fields ? fieldsFrom(data, spec.fields, options) : data && typeof data === 'object' - ? Object.entries(data).map<[string, string]>(([key, value]) => [key, recordCell(value)]) + ? Object.entries(data).map<[string, string]>(([key, value]) => [ + inferHeader(key, inferFormat(key, value)), + inferredCell(key, value), + ]) : [] printRecord(format, fields, data) diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 7eb87392d0c..2b2f5be0190 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -79,8 +79,28 @@ function titleFor(name: string): string { .join(' ') } +/** + * Commander records a hidden command on a private field and offers no getter, + * so this narrows structurally rather than widening the command to `any`. + */ +function isHiddenCommand(command: Command): boolean { + return (command as Command & { _hidden?: boolean })._hidden === true +} + +/** Every option a reader should be taught, in declaration order. */ +function documentedOptions(command: Command): Command['options'] { + return command.options.filter((option) => !option.hidden) +} + +/** + * Hidden entries are excluded for the same reason `--help` omits them: they are + * spellings the CLI has retired and keeps working only so an existing script + * does not break. Documenting one would teach the name being retired. + */ function subcommands(command: Command): Command[] { - return command.commands.filter((child) => child.name() !== HELP_COMMAND) + return command.commands.filter( + (child) => child.name() !== HELP_COMMAND && !isHiddenCommand(child) + ) } /** Depth-first walk yielding every leaf command, in the order commander lists them. */ @@ -144,17 +164,35 @@ function usageLine(entry: DocumentedCommand): string { const name = argument.variadic ? `${argument.name()}...` : argument.name() parts.push(argument.required ? `<${name}>` : `[${name}]`) } - if (entry.command.options.length > 0) parts.push('[options]') + if (documentedOptions(entry.command).length > 0) parts.push('[options]') return parts.join(' ') } +const REQUIRED_SUFFIX = /\s*\(required\)\s*$/i + /** * Commander help already spells required-ness inside the description of a * derived flag. The table states it in its own column, so the trailing marker * would read as "Yes | Workflow ID (required)". */ function stripRequiredSuffix(description: string): string { - return description.replace(/\s*\(required\)\s*$/i, '') + return description.replace(REQUIRED_SUFFIX, '') +} + +/** + * Whether the flag must be supplied for the command to run. + * + * `option.mandatory` alone under-reports it. A destructive command's `--yes` is + * enforced by the runtime rather than by Commander, deliberately: making it + * mandatory would replace the refusal that names the consequence ("This deletes + * the knowledge base and every document in it. Re-run with --yes to confirm.") + * with Commander's bare "required option '--yes' not specified". The flag is + * still required, and the description says so — which is the same marker + * {@link stripRequiredSuffix} removes, so reading it here keeps the column and + * the prose from contradicting each other. + */ +function isRequiredOption(option: Command['options'][number]): boolean { + return option.mandatory || REQUIRED_SUFFIX.test(option.description || '') } /** Help text is written without terminal punctuation; appended clauses need it. */ @@ -208,12 +246,12 @@ function renderArguments(entry: DocumentedCommand): string[] { } function renderOptions(entry: DocumentedCommand): string[] { - const options = entry.command.options + const options = documentedOptions(entry.command) if (options.length === 0) return [] const rows = options.map( (option) => - `| ${code(option.flags)} | ${option.mandatory ? 'Yes' : 'No'} | ${describeOption(option)} |` + `| ${code(option.flags)} | ${isRequiredOption(option) ? 'Yes' : 'No'} | ${describeOption(option)} |` ) return [ @@ -393,7 +431,9 @@ function renderReferencePage( '', '| Option | Description |', '| --- | --- |', - ...program.options.map((option) => `| ${code(option.flags)} | ${describeOption(option)} |`), + ...documentedOptions(program).map( + (option) => `| ${code(option.flags)} | ${describeOption(option)} |` + ), '', ] @@ -467,7 +507,9 @@ function renderIndexPage( '', '| Option | Description |', '| --- | --- |', - ...program.options.map((option) => `| ${code(option.flags)} | ${describeOption(option)} |`), + ...documentedOptions(program).map( + (option) => `| ${code(option.flags)} | ${describeOption(option)} |` + ), '', '## Command groups', '', From c8f559ae778e9667e6be86e3b2214c67722e4ac8 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 17 Aug 2026 16:02:58 -0700 Subject: [PATCH 05/26] fix(workflows,connectors): close pre-merge audit findings (#6783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workflows,connectors): close pre-merge audit findings Recover subblock values orphaned by the id renames in this release, and stop truncated knowledge-base listings from reporting themselves complete. - Add operation-scoped subblock id migrations so a saved workflow's stored value survives a rename. Cloudflare create/update DNS record, ServiceNow read record, and Okta deactivate/delete previously lost their stored value: the create path substituted a seeded default (an A record where the user chose CNAME, and unproxied where they chose proxied), and the update path silently no-opped while reporting success. A migration is used rather than a legacy-id fallback so no subblock id carries two value spaces at runtime. - Webflow, Zendesk: a listing that stops for a reason the connector cannot rule out now reports as capped instead of exhausted. A malformed envelope, an unfollowable continuation link, or an absent collection list previously read as a complete listing and let deletion reconciliation hard-delete every document past the truncation point. - Sentry: pin the listing window in the request rather than inheriting the server default, so the range cannot silently narrow into hard deletes. - Fork sync: a parent re-pick no longer writes a blank over a hidden optional dependent's stored target value, and a required field stays on screen once it is filled. Add hook-level coverage for the submitted payload. - Fork file copy: a file whose name is already taken in a reused target folder is de-duplicated instead of dropped. - Delete an orphaned Shopify OAuth route that built a credential from unsigned cookies. It had no writer, no caller, and no inbound link. - Tailwind: drop two content globs that scanned 5.4k files to emit one unused rule, keeping the ones that fix brand tile icon color. - Correct the API route-count baseline, add an Evernote docs redirect, align library copy with the language rules, and fix a stale turbo filter. * fix(connectors,forking): trim the audit fixes to their minimum A legitimacy review found several changes closed no live defect, and two introduced problems of their own. - Zendesk: narrow the cursor fix to a signal change. Treating a missing meta envelope as truncation had also made the walk follow links.next and keep paginating, and the ticket cursor has no page-depth valve, so a source advertising a next page with no meta could loop without terminating. The page-fetch set now matches the previous behavior; only the flag is new. - Zendesk: drop the search next_page branch. The existing count check already caps every case where a missing key could lose documents. - Webflow: drop the empty-collections flag. The sync engine already blocks the first sync on an empty listing and reconciles only when a second sync agrees, which handles a transient fault better and still removes documents when a source is genuinely emptied. The flag short-circuited that and suppressed reconciliation permanently. Restore the previous loud failure on a non-array envelope, and drop the unreachable collection-id filter. - Webflow: soften a docstring that claimed pagination.total is always present. It is documented optional, so its absence proves nothing either way and treating it as unprovable truncation is the fail-safe reading. - Sentry: drop the pinned statsPeriod. Sentry's issue search floors every query at 90 days in the executor regardless of the request, and the endpoint this release moved away from hit the same floor, so there was no window to close. Keep the tests and the docstring recording that. - Fork copy: drop the renamed counter, which no caller reads. - Repair check-block-registry, which stopped exempting migrated subblock ids when the migration map became an array — `in` was testing array indices. - Drop mdx from a Tailwind content glob that emits nothing, and loosen an exact compiled-SQL assertion to the invariant it was pinning. * fix(migrations): keep a ServiceNow write body off the read projection Review findings from the first round. - A legacy ServiceNow block can hold a Create/Update Record JSON body under `fields` while its stored operation is Read Records: the id served both value spaces before the rename, and a subblock value is not cleared when the operation changes. The scoped migration moved that body onto `readFields`, where it would reach the wire as sysparm_fields. Migration entries can now carry a `whenValue` predicate for the case where the stored operation alone cannot separate two value spaces, and the ServiceNow entry uses it to move only a plausible comma-separated projection. - Type the fork copy test harness instead of using `any`, without weakening it: every predicate shape it does not model still throws rather than matching. - Correct the dependent-omission comments. Omitting a parent-invalidated field preserves the target's stored value on Save and across an undo, where the parent nets out unchanged; on a Sync the written state is source-derived, so what it prevents there is an explicit blank reaching the fields the remap's clearing pass does not cover, nested tool params in particular. Okta's migration scope is left as-is: `okta_remove_user_from_app` and the sendEmail split shipped in the same release, so no saved block can hold legacy state for it, and widening the scope would promote an activation-era value onto the deactivation switch. Tests document the boundary. * chore(forking): move the fork-sync changes to their own PR The dependent-omission fix and the fork file-copy de-duplication are reviewed separately in #6787. They are the only changes here that overlap #6776, and they carry their own design tradeoff, so they should not ride along with the unrelated audit fixes in this PR. * fix(migrations): separate a ServiceNow write body from a projection by parsing The guard tested for a `{` or `[` prefix, so a stored scalar body — `true`, `"short_description"`, `42` — read as a field list and was promoted onto `readFields`, where it would go out as sysparm_fields. A Create/Update Record body is JSON and a projection is a bare comma-separated field list, which is never valid JSON, so parsing is the whole test rather than a guess at its opening character. Ambiguity still resolves to "not a projection", leaving the value where the Create/Update control owns it. * test(connectors,credentials): tie two assertions to what they actually prove - Webflow: a non-array collections envelope reaching `for...of` throws, which is the intended loud failure. Assert the spec-mandated TypeError plus a single request and no write-back, rather than matching V8's wording. - Credentials: the second guard test cannot observe "not deleted" — the proxy driver replays canned rows — so name it for what it does verify, that the reference check carries no workspace predicate and an empty RETURNING logs nothing. Making the driver decide the outcome would fake the database. - Drop `vi.importActual`; a plain `drizzle-orm/pg-proxy` import works now that `drizzle-orm` is un-mocked. * fix(migrations): identify a ServiceNow projection by its own shape Recognising a write body was the wrong way round. A saved body is not always well-formed: it can be a half-typed draft or carry an unquoted block reference, so neither "opens with a brace" nor "fails to parse as JSON" identifies one — and a body misread as a projection is moved to readFields with its original key dropped, losing the draft. Match the projection instead: a comma-separated list of ServiceNow field names, which are word characters plus the dot of a dotted walk. A brace, quote, colon, angle bracket or interior space fails that shape. Parsing then removes the bare scalars that satisfy it by accident. --- .../migrate-application-operation/SKILL.md | 2 +- apps/docs/lib/redirects.ts | 4 + .../api/auth/oauth2/shopify/store/route.ts | 71 --- apps/sim/blocks/blocks/cloudflare.test.ts | 445 ++++++++++++++++ apps/sim/blocks/blocks/okta.test.ts | 240 ++++++++- apps/sim/blocks/blocks/servicenow.test.ts | 233 +++++++++ apps/sim/connectors/sentry/meta.ts | 7 + apps/sim/connectors/sentry/sentry.test.ts | 226 ++++++++ apps/sim/connectors/sentry/sentry.ts | 18 +- apps/sim/connectors/webflow/webflow.test.ts | 177 ++++++- apps/sim/connectors/webflow/webflow.ts | 32 +- apps/sim/connectors/zendesk/zendesk.test.ts | 273 ++++++++++ apps/sim/connectors/zendesk/zendesk.ts | 126 ++++- .../automation-anywhere-alternative/index.mdx | 4 +- .../lib/api/contracts/oauth-connections.ts | 7 - apps/sim/lib/credentials/deletion.test.ts | 139 +++++ apps/sim/lib/credentials/deletion.ts | 11 +- .../migrations/subblock-migrations.test.ts | 272 +++++++++- .../migrations/subblock-migrations.ts | 482 ++++++++++++------ apps/sim/scripts/check-block-registry.ts | 4 +- apps/sim/tailwind.config.ts | 2 - findings.txt | 19 - scripts/check-api-validation-contracts.ts | 4 +- 23 files changed, 2502 insertions(+), 296 deletions(-) delete mode 100644 apps/sim/app/api/auth/oauth2/shopify/store/route.ts create mode 100644 apps/sim/blocks/blocks/cloudflare.test.ts create mode 100644 apps/sim/blocks/blocks/servicenow.test.ts create mode 100644 apps/sim/connectors/sentry/sentry.test.ts create mode 100644 apps/sim/lib/credentials/deletion.test.ts delete mode 100644 findings.txt diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md index 6caa5ff7c57..7b51087bb60 100644 --- a/.agents/skills/migrate-application-operation/SKILL.md +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -317,7 +317,7 @@ Run at minimum: ```bash bunx vitest run bunx biome check -bunx turbo run type-check --filter=sim --filter=@sim/auth +bunx turbo run type-check --filter=@sim/app --filter=@sim/auth bun run check:api-validation:strict git diff --check ``` diff --git a/apps/docs/lib/redirects.ts b/apps/docs/lib/redirects.ts index 8098749efa5..a43a03b2471 100644 --- a/apps/docs/lib/redirects.ts +++ b/apps/docs/lib/redirects.ts @@ -81,6 +81,10 @@ export const DOCS_REDIRECTS: DocsRedirect[] = [ destination: '/agents/custom-tools', permanent: true, }, + // evernote integration page removed; without this the /tools/:slug rule below + // would permanently redirect /tools/evernote into a 404. + { source: '/tools/evernote', destination: '/integrations', permanent: true }, + { source: '/integrations/evernote', destination: '/integrations', permanent: true }, { source: '/tools', destination: '/integrations', permanent: true }, { source: '/tools/:slug', destination: '/integrations/:slug', permanent: true }, // Old blocks/triggers index pages were folded into the workflows overview. diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts deleted file mode 100644 index d3c84d68883..00000000000 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - shopifyShopDomainSchema, - shopifyStoreCookieSchema, -} from '@/lib/api/contracts/oauth-connections' -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 { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' - -const logger = createLogger('ShopifyStore') - -export const dynamic = 'force-dynamic' - -export const GET = withRouteHandler(async (request: NextRequest) => { - const baseUrl = getBaseUrl() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn('Unauthorized attempt to store Shopify token') - return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`) - } - - const parsedCookies = shopifyStoreCookieSchema.safeParse({ - accessToken: request.cookies.get('shopify_pending_token')?.value, - shopDomain: request.cookies.get('shopify_pending_shop')?.value, - scope: request.cookies.get('shopify_pending_scope')?.value || undefined, - returnUrl: request.cookies.get('shopify_return_url')?.value || undefined, - }) - - if (!parsedCookies.success) { - logger.error('Missing token or shop domain in cookies') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`) - } - const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data - const draftId = request.cookies.get('shopify_credential_draft_id')?.value - - if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { - logger.error('Invalid shop domain format in cookie', { shopDomain }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`) - } - - await completeShopifyOAuthConnection({ - accessToken, - shopDomain, - scope, - userId: session.user.id, - draftId, - signal: request.signal, - }) - - const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace` - const finalUrl = new URL(redirectUrl) - finalUrl.searchParams.set('shopify_connected', 'true') - - const response = NextResponse.redirect(finalUrl.toString()) - response.cookies.delete('shopify_pending_token') - response.cookies.delete('shopify_pending_shop') - response.cookies.delete('shopify_pending_scope') - response.cookies.delete('shopify_return_url') - response.cookies.delete('shopify_credential_draft_id') - - return response - } catch (error) { - logger.error('Error storing Shopify token:', error) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_store_error`) - } -}) diff --git a/apps/sim/blocks/blocks/cloudflare.test.ts b/apps/sim/blocks/blocks/cloudflare.test.ts new file mode 100644 index 00000000000..fc8047e37a8 --- /dev/null +++ b/apps/sim/blocks/blocks/cloudflare.test.ts @@ -0,0 +1,445 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CloudflareBlock } from '@/blocks/blocks/cloudflare' + +const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() })) + +vi.mock('@/blocks/registry', () => ({ + getBlock: mockGetBlock, + getAllBlocks: vi.fn(() => []), + getLatestBlock: vi.fn(() => undefined), + getBlockRegistry: vi.fn(() => ({})), + getBlockByToolName: vi.fn(() => undefined), + getBlocksByCategory: vi.fn(() => []), +})) + +import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations' +import { extractBlockParams } from '@/serializer' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * Block state exactly as the canvas persists it: one entry per sub-block id, + * and nothing else. A workflow saved before an id rename carries only the ids + * that existed then, which is what makes the migration's "target absent" test + * mean "this state predates the rename". + */ +function blockState(values: Record, advancedMode = false): BlockState { + return { + id: 'block-1', + type: 'cloudflare', + name: 'Cloudflare 1', + position: { x: 0, y: 0 }, + advancedMode, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) + ), + outputs: {}, + enabled: true, + } as unknown as BlockState +} + +/** + * Block state as it exists for a block CREATED after the renames: `prepareBlockState` + * materializes an entry for every declared sub-block and the add-block write + * persists that map wholesale, so every current id is present — seeded, or + * `null` where the control has no seed. + */ +function modernBlockState(overrides: Record, advancedMode = false): BlockState { + const subBlocks: Record = {} + for (const subBlock of CloudflareBlock.subBlocks) { + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: typeof subBlock.value === 'function' ? subBlock.value({}) : null, + } + } + for (const [id, value] of Object.entries(overrides)) { + const declared = CloudflareBlock.subBlocks.find((subBlock) => subBlock.id === id) + subBlocks[id] = { id, type: declared?.type ?? 'short-input', value } + } + + return { + id: 'block-1', + type: 'cloudflare', + name: 'Cloudflare 1', + position: { x: 0, y: 0 }, + advancedMode, + subBlocks, + outputs: {}, + enabled: true, + } as unknown as BlockState +} + +/** + * Mirror the executor merge — `finalInputs = { ...inputs, ...transformedParams }` + * in `executor/handlers/generic/generic-handler.ts` — so a key the mapper omits + * keeps its raw block value and only an explicit `undefined` erases it. + */ +function mapParams(params: Record): Record { + const transform = CloudflareBlock.tools.config?.params + if (!transform) throw new Error('Cloudflare block has no params transform') + return { ...params, ...transform(params) } +} + +/** The real load-time pipeline: migrate stored state, serialize, then map. */ +function runPipeline(state: BlockState): Record { + const { blocks } = migrateSubblockIds({ 'block-1': state }) + return mapParams(extractBlockParams(blocks['block-1'])) +} + +const CREDENTIALS = { apiKey: 'cf-token' } + +describe('Cloudflare DNS write values saved before the id rename', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(CloudflareBlock) + }) + + describe('create_dns_record', () => { + it('carries a record type saved under the legacy `type` id onto the wire', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + type: 'CNAME', + name: 'app.example.com', + content: 'origin.example.com', + }) + ) + + expect(mapped.type).toBe('CNAME') + }) + + /** + * The headline defect: a proxied A record recreated unproxied publishes the + * origin IP in public DNS and bypasses the WAF/CDN. + */ + it('carries a proxied flag saved under the legacy `proxied` id onto the wire', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + type: 'A', + name: 'app.example.com', + content: '192.0.2.1', + proxied: 'true', + }) + ) + + expect(mapped.proxied).toBe(true) + }) + + it('carries tags saved under the legacy `tags` id onto the wire', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + type: 'A', + name: 'app.example.com', + content: '192.0.2.1', + tags: 'production', + }) + ) + + expect(mapped.tags).toBe('production') + }) + + it('carries every field of one shipped proxied A record', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + type: 'A', + name: 'app.example.com', + content: '192.0.2.1', + ttl: '300', + proxied: 'true', + comment: 'edge', + tags: 'production', + }) + ) + + expect(mapped).toMatchObject({ + operation: 'create_dns_record', + zoneId: 'zone-1', + type: 'A', + name: 'app.example.com', + content: '192.0.2.1', + ttl: 300, + proxied: true, + comment: 'edge', + tags: 'production', + }) + }) + + it('recovers the legacy value with the block advanced toggle on', () => { + const mapped = runPipeline( + blockState( + { + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + type: 'CNAME', + name: 'app.example.com', + content: 'origin.example.com', + proxied: 'true', + }, + true + ) + ) + + expect(mapped).toMatchObject({ type: 'CNAME', proxied: true }) + }) + }) + + describe('update_dns_record', () => { + it('carries content saved under the legacy `content` id onto the wire', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'update_dns_record', + zoneId: 'zone-1', + recordId: 'record-1', + content: '203.0.113.9', + }) + ) + + expect(mapped.content).toBe('203.0.113.9') + }) + + it('carries type, name, proxied, and tags saved under their legacy ids', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'update_dns_record', + zoneId: 'zone-1', + recordId: 'record-1', + type: 'A', + name: 'app.example.com', + content: '203.0.113.9', + proxied: 'true', + tags: 'production', + }) + ) + + expect(mapped).toMatchObject({ + type: 'A', + name: 'app.example.com', + content: '203.0.113.9', + proxied: true, + tags: 'production', + }) + }) + }) + + describe('list filters that were also renamed', () => { + it('carries a sort field saved under the legacy `order` id onto the wire', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'list_dns_records', + zoneId: 'zone-1', + order: 'ttl', + }) + ) + + expect(mapped.order).toBe('ttl') + }) + + it('carries a certificate status saved under the legacy `status` id onto the wire', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'list_certificates', + zoneId: 'zone-1', + status: 'active', + }) + ) + + expect(mapped.status).toBe('active') + }) + }) +}) + +/** + * The renamed ids all stayed live for a different operation. The migration is + * scoped so those value spaces are untouched. + */ +describe('Cloudflare value spaces the rename left alone', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(CloudflareBlock) + }) + + it('still filters a DNS record list by type, name, content, and proxied', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'list_dns_records', + zoneId: 'zone-1', + type: 'MX', + name: 'mail.example.com', + content: 'mx1.example.com', + proxied: 'false', + }) + ) + + expect(mapped).toMatchObject({ + type: 'MX', + name: 'mail.example.com', + content: 'mx1.example.com', + proxied: false, + }) + }) + + it('still purges cache by the tags stored under `tags`', () => { + const mapped = runPipeline( + blockState({ + ...CREDENTIALS, + operation: 'purge_cache', + zoneId: 'zone-1', + tags: 'static,images', + }) + ) + + expect(mapped.tags).toBe('static,images') + }) + + it('leaves a list filter in place instead of migrating it to a write control', () => { + const { blocks } = migrateSubblockIds({ + 'block-1': blockState({ + ...CREDENTIALS, + operation: 'list_dns_records', + zoneId: 'zone-1', + type: 'MX', + name: 'mail.example.com', + }), + }) + + const subBlocks = blocks['block-1'].subBlocks + expect(subBlocks.type?.value).toBe('MX') + expect(subBlocks.name?.value).toBe('mail.example.com') + expect(subBlocks.recordType).toBeUndefined() + expect(subBlocks.updateRecordType).toBeUndefined() + expect(subBlocks.updateRecordName).toBeUndefined() + }) +}) + +/** + * A block created after the renames carries every current id, which is exactly + * what tells the migration the state is not legacy. Nothing may move. + */ +describe('Cloudflare state written after the rename is never re-migrated', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(CloudflareBlock) + }) + + it('does not promote a stale list type filter onto a create record', () => { + const mapped = runPipeline( + modernBlockState({ + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + // Typed while the block was on `list_dns_records`, then the operation + // changed. `recordType` is still sitting at its seeded default. + type: 'MX', + name: 'app.example.com', + content: '192.0.2.1', + }) + ) + + expect(mapped.type).toBe('A') + }) + + it('does not promote a stale list name filter onto a record rename', () => { + const mapped = runPipeline( + modernBlockState({ + ...CREDENTIALS, + operation: 'update_dns_record', + zoneId: 'zone-1', + recordId: 'record-1', + name: 'ci-pipeline', + }) + ) + + expect(mapped.name).toBeUndefined() + }) + + it('does not promote a stale purge tag list onto a create record', () => { + const mapped = runPipeline( + modernBlockState({ + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + name: 'app.example.com', + content: '192.0.2.1', + tags: 'static,images', + }) + ) + + expect(mapped.tags).toBeUndefined() + }) + + it('does not overwrite a record type the user picked', () => { + const { blocks } = migrateSubblockIds({ + 'block-1': modernBlockState({ + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + type: 'MX', + recordType: 'CNAME', + }), + }) + + expect(blocks['block-1'].subBlocks.recordType?.value).toBe('CNAME') + }) + + it('reports no migration for state that already uses the current ids', () => { + const { migrated } = migrateSubblockIds({ + 'block-1': modernBlockState({ + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + recordType: 'CNAME', + }), + }) + + expect(migrated).toBe(false) + }) +}) + +/** + * The migration rewrites stored state, so running it twice must be a no-op — + * otherwise every load would rewrite the row and re-fire the persist. + */ +describe('Cloudflare migration convergence', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(CloudflareBlock) + }) + + it('is a no-op the second time it runs', () => { + const legacy = blockState({ + ...CREDENTIALS, + operation: 'create_dns_record', + zoneId: 'zone-1', + type: 'CNAME', + proxied: 'true', + }) + + const first = migrateSubblockIds({ 'block-1': legacy }) + expect(first.migrated).toBe(true) + + const second = migrateSubblockIds(first.blocks) + expect(second.migrated).toBe(false) + expect(second.blocks['block-1'].subBlocks.recordType?.value).toBe('CNAME') + expect(second.blocks['block-1'].subBlocks.recordProxied?.value).toBe('true') + }) +}) diff --git a/apps/sim/blocks/blocks/okta.test.ts b/apps/sim/blocks/blocks/okta.test.ts index 53e69305396..de4bf79a00e 100644 --- a/apps/sim/blocks/blocks/okta.test.ts +++ b/apps/sim/blocks/blocks/okta.test.ts @@ -1,9 +1,43 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { OktaBlock } from '@/blocks/blocks/okta' +const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() })) + +vi.mock('@/blocks/registry', () => ({ + getBlock: mockGetBlock, + getAllBlocks: vi.fn(() => []), + getLatestBlock: vi.fn(() => undefined), + getBlockRegistry: vi.fn(() => ({})), + getBlockByToolName: vi.fn(() => undefined), + getBlocksByCategory: vi.fn(() => []), +})) + +import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations' +import { extractBlockParams } from '@/serializer' +import type { BlockState } from '@/stores/workflows/workflow/types' +import { isOktaFlagEnabled } from '@/tools/okta/utils' + +/** + * Build the block state the canvas persists: one entry per stored sub-block id. + * A workflow saved before an id rename only carries the ids that existed then. + */ +function legacyBlockState(values: Record): BlockState { + return { + id: 'block-1', + type: 'okta', + name: 'Okta 1', + position: { x: 0, y: 0 }, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) + ), + outputs: {}, + enabled: true, + } as unknown as BlockState +} + /** * The generic block handler runs `{ ...inputs, ...transformedParams }`, so the * transform can only drop a value by assigning `undefined` to its key. Omitting @@ -86,6 +120,210 @@ describe('Okta block params transform', () => { }) }) +/** Block state for a block created after the split: every current id present. */ +function modernBlockState(overrides: Record): BlockState { + const subBlocks: Record = {} + for (const subBlock of OktaBlock.subBlocks) { + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: typeof subBlock.value === 'function' ? subBlock.value({}) : null, + } + } + for (const [id, value] of Object.entries(overrides)) { + const declared = OktaBlock.subBlocks.find((subBlock) => subBlock.id === id) + subBlocks[id] = { id, type: declared?.type ?? 'short-input', value } + } + + return { + id: 'block-1', + type: 'okta', + name: 'Okta 1', + position: { x: 0, y: 0 }, + subBlocks, + outputs: {}, + enabled: true, + } as unknown as BlockState +} + +/** The real load-time pipeline: migrate stored state, serialize, then map. */ +function runPipeline(state: BlockState): Record { + const { blocks } = migrateSubblockIds({ 'block-1': state }) + return merge(extractBlockParams(blocks['block-1'])) +} + +/** + * One `sendEmail` switch used to serve activation, reset, deactivation, and + * deletion. Okta's API default is not uniform across those, so it split in two + * — orphaning every deactivation toggle a saved workflow had stored. + */ +describe('Okta deactivation toggle saved before the switch split', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(OktaBlock) + }) + + it('carries a legacy deactivation toggle onto the wire', () => { + const mapped = runPipeline( + legacyBlockState({ + operation: 'okta_deactivate_user', + apiKey: 'token', + domain: 'dev-1.okta.com', + userId: '00u1', + sendEmail: 'true', + }) + ) + + expect(isOktaFlagEnabled(mapped.sendEmail)).toBe(true) + }) + + it('carries a legacy delete toggle onto the wire', () => { + const mapped = runPipeline( + legacyBlockState({ + operation: 'okta_delete_user', + apiKey: 'token', + domain: 'dev-1.okta.com', + userId: '00u1', + sendEmail: 'true', + }) + ) + + expect(isOktaFlagEnabled(mapped.sendEmail)).toBe(true) + }) + + it('moves the legacy toggle onto the current id in stored state', () => { + const { blocks, migrated } = migrateSubblockIds({ + 'block-1': legacyBlockState({ + operation: 'okta_deactivate_user', + apiKey: 'token', + domain: 'dev-1.okta.com', + userId: '00u1', + sendEmail: 'true', + }), + }) + + expect(migrated).toBe(true) + expect(blocks['block-1'].subBlocks.sendDeactivationEmail?.value).toBe('true') + expect(blocks['block-1'].subBlocks.sendEmail).toBeUndefined() + }) +}) + +/** + * `sendEmail` stays live for activation and password reset, so the migration is + * scoped to the deactivation half and must leave the other half alone. + */ +describe('Okta activation toggle the rename left alone', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(OktaBlock) + }) + + it('still sends the activation email from `sendEmail`', () => { + const mapped = runPipeline( + legacyBlockState({ + operation: 'okta_activate_user', + apiKey: 'token', + domain: 'dev-1.okta.com', + userId: '00u1', + sendEmail: 'true', + }) + ) + + expect(isOktaFlagEnabled(mapped.sendEmail)).toBe(true) + }) + + it('leaves an activation toggle under `sendEmail` instead of migrating it', () => { + const { blocks } = migrateSubblockIds({ + 'block-1': legacyBlockState({ + operation: 'okta_activate_user', + apiKey: 'token', + domain: 'dev-1.okta.com', + userId: '00u1', + sendEmail: 'false', + }), + }) + + expect(blocks['block-1'].subBlocks.sendEmail?.value).toBe('false') + expect(blocks['block-1'].subBlocks.sendDeactivationEmail).toBeUndefined() + }) + + it('honours a suppressed activation email', () => { + const mapped = runPipeline( + legacyBlockState({ + operation: 'okta_activate_user', + apiKey: 'token', + domain: 'dev-1.okta.com', + userId: '00u1', + sendEmail: 'false', + }) + ) + + /** + * Assert the value itself, not just the resolved flag: activation defaults + * to sending when the param is absent, so a dropped `false` and an honoured + * one both read as "not enabled" one layer down. + */ + expect(mapped.sendEmail).toBe('false') + expect(isOktaFlagEnabled(mapped.sendEmail)).toBe(false) + }) +}) + +describe('Okta state written after the split is never re-migrated', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(OktaBlock) + }) + + /** + * `sendEmail` is seeded `'true'` on every newly placed block, so promoting it + * would silently notify every user a workflow deactivates. + */ + it('does not promote the seeded activation toggle onto a deactivation', () => { + const mapped = runPipeline( + modernBlockState({ + operation: 'okta_deactivate_user', + apiKey: 'token', + domain: 'dev-1.okta.com', + userId: '00u1', + }) + ) + + expect(mapped.sendEmail).toBeUndefined() + }) + + it('does not overwrite a deactivation toggle the user set', () => { + const { blocks } = migrateSubblockIds({ + 'block-1': modernBlockState({ + operation: 'okta_deactivate_user', + apiKey: 'token', + domain: 'dev-1.okta.com', + userId: '00u1', + sendEmail: 'true', + sendDeactivationEmail: 'false', + }), + }) + + expect(blocks['block-1'].subBlocks.sendDeactivationEmail?.value).toBe('false') + }) + + it('is a no-op the second time it runs', () => { + const first = migrateSubblockIds({ + 'block-1': legacyBlockState({ + operation: 'okta_deactivate_user', + apiKey: 'token', + domain: 'dev-1.okta.com', + userId: '00u1', + sendEmail: 'true', + }), + }) + expect(first.migrated).toBe(true) + + const second = migrateSubblockIds(first.blocks) + expect(second.migrated).toBe(false) + expect(second.blocks['block-1'].subBlocks.sendDeactivationEmail?.value).toBe('true') + }) +}) + describe('Okta block outputs', () => { it('declares only fields a tool emits at the top level', () => { const declared = Object.keys(OktaBlock.outputs) diff --git a/apps/sim/blocks/blocks/servicenow.test.ts b/apps/sim/blocks/blocks/servicenow.test.ts new file mode 100644 index 00000000000..056a5e41400 --- /dev/null +++ b/apps/sim/blocks/blocks/servicenow.test.ts @@ -0,0 +1,233 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ServiceNowBlock } from '@/blocks/blocks/servicenow' + +const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() })) + +vi.mock('@/blocks/registry', () => ({ + getBlock: mockGetBlock, + getAllBlocks: vi.fn(() => []), + getLatestBlock: vi.fn(() => undefined), + getBlockRegistry: vi.fn(() => ({})), + getBlockByToolName: vi.fn(() => undefined), + getBlocksByCategory: vi.fn(() => []), +})) + +import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations' +import { extractBlockParams } from '@/serializer' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * Block state exactly as the canvas persists it. A workflow saved before the + * Read Records projection moved off `fields` carries only the ids that existed + * then, which is what marks the state as legacy. + */ +function legacyBlockState(values: Record): BlockState { + return { + id: 'block-1', + type: 'servicenow', + name: 'ServiceNow 1', + position: { x: 0, y: 0 }, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) + ), + outputs: {}, + enabled: true, + } as unknown as BlockState +} + +/** Block state for a block created after the split: every current id present. */ +function modernBlockState(overrides: Record): BlockState { + const subBlocks: Record = {} + for (const subBlock of ServiceNowBlock.subBlocks) { + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: typeof subBlock.value === 'function' ? subBlock.value({}) : null, + } + } + for (const [id, value] of Object.entries(overrides)) { + const declared = ServiceNowBlock.subBlocks.find((subBlock) => subBlock.id === id) + subBlocks[id] = { id, type: declared?.type ?? 'short-input', value } + } + + return { + id: 'block-1', + type: 'servicenow', + name: 'ServiceNow 1', + position: { x: 0, y: 0 }, + subBlocks, + outputs: {}, + enabled: true, + } as unknown as BlockState +} + +function mapParams(params: Record): Record { + const transform = ServiceNowBlock.tools.config?.params + if (!transform) throw new Error('ServiceNow block has no params transform') + return { ...params, ...transform(params) } +} + +/** The real load-time pipeline: migrate stored state, serialize, then map. */ +function runPipeline(state: BlockState): Record { + const { blocks } = migrateSubblockIds({ 'block-1': state }) + return mapParams(extractBlockParams(blocks['block-1'])) +} + +const CREDENTIALS = { + instanceUrl: 'https://acme.service-now.com', + username: 'u', + password: 'p', +} + +describe('ServiceNow Read Records projection saved before the id split', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(ServiceNowBlock) + }) + + /** + * Without the migration every saved read silently widened to all columns of + * every row, because the projection stayed stranded under `fields`. + */ + it('carries a legacy projection onto the wire as the field list', () => { + const mapped = runPipeline( + legacyBlockState({ + ...CREDENTIALS, + operation: 'servicenow_read_record', + tableName: 'incident', + query: 'active=true', + limit: '10', + fields: 'number,short_description,state', + }) + ) + + expect(mapped.fields).toBe('number,short_description,state') + }) + + it('moves the legacy projection onto the current id in stored state', () => { + const { blocks, migrated } = migrateSubblockIds({ + 'block-1': legacyBlockState({ + ...CREDENTIALS, + operation: 'servicenow_read_record', + tableName: 'incident', + fields: 'number,state', + }), + }) + + expect(migrated).toBe(true) + expect(blocks['block-1'].subBlocks.readFields?.value).toBe('number,state') + expect(blocks['block-1'].subBlocks.fields).toBeUndefined() + }) +}) + +/** + * `fields` is still the Create/Update Record JSON body — a different value + * space. The migration is scoped to Read Records so that body is untouched. + */ +describe('ServiceNow create and update bodies the rename left alone', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(ServiceNowBlock) + }) + + it('still parses a JSON body stored under `fields` on Create Record', () => { + const mapped = runPipeline( + legacyBlockState({ + ...CREDENTIALS, + operation: 'servicenow_create_record', + tableName: 'incident', + fields: '{"short_description":"Network outage","priority":"1"}', + }) + ) + + expect(mapped.fields).toEqual({ + short_description: 'Network outage', + priority: '1', + }) + }) + + it('still parses a JSON body stored under `fields` on Update Record', () => { + const mapped = runPipeline( + legacyBlockState({ + ...CREDENTIALS, + operation: 'servicenow_update_record', + tableName: 'incident', + sysId: 'abc', + fields: '{"state":"2"}', + }) + ) + + expect(mapped.fields).toEqual({ state: '2' }) + }) + + it('leaves a create body under `fields` instead of migrating it', () => { + const { blocks } = migrateSubblockIds({ + 'block-1': legacyBlockState({ + ...CREDENTIALS, + operation: 'servicenow_create_record', + tableName: 'incident', + fields: '{"short_description":"Network outage"}', + }), + }) + + expect(blocks['block-1'].subBlocks.fields?.value).toBe('{"short_description":"Network outage"}') + expect(blocks['block-1'].subBlocks.readFields).toBeUndefined() + }) +}) + +describe('ServiceNow state written after the split is never re-migrated', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue(ServiceNowBlock) + }) + + /** + * The cross-operation leak the split exists to prevent: a create body left + * behind and then promoted would go out as `sysparm_fields=[object Object]`. + */ + it('does not promote a stale create body onto the Read Records projection', () => { + const mapped = runPipeline( + modernBlockState({ + ...CREDENTIALS, + operation: 'servicenow_read_record', + tableName: 'incident', + fields: '{"short_description":"Network outage"}', + }) + ) + + expect(mapped.fields).toBeUndefined() + }) + + it('does not overwrite a projection the user picked', () => { + const { blocks } = migrateSubblockIds({ + 'block-1': modernBlockState({ + ...CREDENTIALS, + operation: 'servicenow_read_record', + tableName: 'incident', + fields: 'number,short_description', + readFields: 'number,state', + }), + }) + + expect(blocks['block-1'].subBlocks.readFields?.value).toBe('number,state') + }) + + it('is a no-op the second time it runs', () => { + const first = migrateSubblockIds({ + 'block-1': legacyBlockState({ + ...CREDENTIALS, + operation: 'servicenow_read_record', + tableName: 'incident', + fields: 'number,state', + }), + }) + expect(first.migrated).toBe(true) + + const second = migrateSubblockIds(first.blocks) + expect(second.migrated).toBe(false) + expect(second.blocks['block-1'].subBlocks.readFields?.value).toBe('number,state') + }) +}) diff --git a/apps/sim/connectors/sentry/meta.ts b/apps/sim/connectors/sentry/meta.ts index a619f26b7a8..bfa4f9ba847 100644 --- a/apps/sim/connectors/sentry/meta.ts +++ b/apps/sim/connectors/sentry/meta.ts @@ -14,6 +14,13 @@ import type { ConnectorMeta } from '@/connectors/types' * query (e.g. drop `is:unresolved`). When `maxIssues` caps the listing, the * engine sets `listingCapped` and skips deletion, so capped runs never remove * unseen issues. + * + * "Aged out" has a hard bound worth stating: Sentry's issue search floors every + * query at 90 days (less, on an install whose event retention is shorter), so an + * issue with no event in that window matches no query and is removed on the next + * full sync. That bound is the source's, not this connector's — it applied + * identically to the project-scoped listing endpoint this connector used before — + * and no setting here can widen it. */ export const DEFAULT_QUERY = 'is:unresolved' diff --git a/apps/sim/connectors/sentry/sentry.test.ts b/apps/sim/connectors/sentry/sentry.test.ts new file mode 100644 index 00000000000..a224346e171 --- /dev/null +++ b/apps/sim/connectors/sentry/sentry.test.ts @@ -0,0 +1,226 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() })) + +vi.mock('@/lib/knowledge/documents/secure-fetch.server', () => ({ + secureFetchWithRetry: mockFetch, +})) + +import { sentryConnector } from '@/connectors/sentry/sentry' + +const ACCESS_TOKEN = 'test-token' + +const BASE_CONFIG = { + organization: 'acme', + project: 'web', +} + +function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }) +} + +function issueFixture(id: string, overrides: Record = {}) { + return { + id, + shortId: `WEB-${id}`, + title: `Boom ${id}`, + permalink: `https://sentry.io/organizations/acme/issues/${id}/`, + level: 'error', + status: 'unresolved', + count: '12', + userCount: 3, + firstSeen: '2024-01-01T00:00:00.000Z', + lastSeen: '2024-02-01T00:00:00.000Z', + ...overrides, + } +} + +/** A `Link` header advertising another page, exactly as Sentry emits it. */ +const NEXT_PAGE_LINK = { + Link: '; rel="next"; results="true"; cursor="0:100:0"', +} + +function requestUrl(callIndex = 0): URL { + const call = mockFetch.mock.calls[callIndex] + if (!call) throw new Error(`No fetch call at index ${callIndex}`) + return new URL(String(call[0])) +} + +beforeEach(() => { + mockFetch.mockReset() +}) + +/** + * `listingCapped` must track whether this listing pass actually withheld issues + * it could otherwise have returned — not how the connector happens to be + * configured. The listing's date range is pinned to the widest range Sentry's + * issue search can serve, so it never withholds anything and never contributes + * to the flag; only `maxIssues` does. + */ +describe('sentryConnector.listDocuments listing completeness', () => { + it('does not flag listingCapped when the listing exhausts the source', async () => { + mockFetch.mockResolvedValue(jsonResponse([issueFixture('1'), issueFixture('2')])) + const syncContext: Record = {} + + const result = await sentryConnector.listDocuments( + ACCESS_TOKEN, + { ...BASE_CONFIG }, + undefined, + syncContext + ) + + expect(result.hasMore).toBe(false) + expect(result.documents).toHaveLength(2) + expect(syncContext.listingCapped).toBeUndefined() + }) + + it('does not flag listingCapped when the source is legitimately empty', async () => { + mockFetch.mockResolvedValue(jsonResponse([])) + const syncContext: Record = {} + + await sentryConnector.listDocuments(ACCESS_TOKEN, { ...BASE_CONFIG }, undefined, syncContext) + + expect(syncContext.listingCapped).toBeUndefined() + }) + + it('does not flag listingCapped when maxIssues lands exactly on exhaustion', async () => { + mockFetch.mockResolvedValue(jsonResponse([issueFixture('1'), issueFixture('2')])) + const syncContext: Record = {} + + await sentryConnector.listDocuments( + ACCESS_TOKEN, + { ...BASE_CONFIG, maxIssues: '2' }, + undefined, + syncContext + ) + + expect(syncContext.listingCapped).toBeUndefined() + }) + + it('flags listingCapped when maxIssues stops short of a source that has more pages', async () => { + mockFetch.mockResolvedValue( + jsonResponse([issueFixture('1'), issueFixture('2')], 200, NEXT_PAGE_LINK) + ) + const syncContext: Record = {} + + const result = await sentryConnector.listDocuments( + ACCESS_TOKEN, + { ...BASE_CONFIG, maxIssues: '2' }, + undefined, + syncContext + ) + + expect(result.hasMore).toBe(false) + expect(syncContext.listingCapped).toBe(true) + }) + + it('flags listingCapped when maxIssues drops issues from the page it received', async () => { + mockFetch.mockResolvedValue(jsonResponse([issueFixture('1'), issueFixture('2')])) + const syncContext: Record = {} + + const result = await sentryConnector.listDocuments( + ACCESS_TOKEN, + { ...BASE_CONFIG, maxIssues: '1' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(1) + expect(syncContext.listingCapped).toBe(true) + }) +}) + +describe('sentryConnector.listDocuments request shape', () => { + it('lists through the org-scoped issues endpoint without a date range', async () => { + mockFetch.mockResolvedValue(jsonResponse([])) + + await sentryConnector.listDocuments(ACCESS_TOKEN, { ...BASE_CONFIG }, undefined, {}) + + const url = requestUrl() + expect(url.origin + url.pathname).toBe('https://sentry.io/api/0/organizations/acme/issues/') + expect(url.searchParams.get('statsPeriod')).toBeNull() + }) + + it('sends the org-scoped listing params', async () => { + mockFetch.mockResolvedValue(jsonResponse([])) + + await sentryConnector.listDocuments( + ACCESS_TOKEN, + { ...BASE_CONFIG, statsPeriod: '24h', environment: 'production' }, + 'cursor-1', + {} + ) + + expect(Object.fromEntries(requestUrl().searchParams)).toEqual({ + project: 'web', + query: 'is:unresolved', + sort: 'new', + limit: '100', + groupStatsPeriod: '24h', + environment: 'production', + cursor: 'cursor-1', + }) + }) + + it('sends the configured stats period as groupStatsPeriod, never as statsPeriod', async () => { + mockFetch.mockResolvedValue(jsonResponse([])) + + await sentryConnector.listDocuments( + ACCESS_TOKEN, + { ...BASE_CONFIG, statsPeriod: '14d' }, + undefined, + {} + ) + + const url = requestUrl() + expect(url.searchParams.get('statsPeriod')).toBeNull() + expect(url.searchParams.get('groupStatsPeriod')).toBe('14d') + }) + + it('ignores an unrecognized date-range key rather than transmitting it', async () => { + mockFetch.mockResolvedValue(jsonResponse([])) + + await sentryConnector.listDocuments( + ACCESS_TOKEN, + { ...BASE_CONFIG, issueWindow: '9000d' }, + undefined, + {} + ) + + const url = requestUrl() + expect(url.searchParams.get('statsPeriod')).toBeNull() + expect(url.searchParams.get('issueWindow')).toBeNull() + }) +}) + +describe('sentryConnector.validateConfig', () => { + it('rejects a per-issue stats period Sentry does not accept', async () => { + const result = await sentryConnector.validateConfig(ACCESS_TOKEN, { + ...BASE_CONFIG, + statsPeriod: '90d', + }) + + expect(result.valid).toBe(false) + expect(result.error).toContain('Stats period') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('probes the same issues endpoint the sync lists through', async () => { + mockFetch.mockResolvedValue(jsonResponse([])) + + const result = await sentryConnector.validateConfig(ACCESS_TOKEN, { ...BASE_CONFIG }) + + expect(result.valid).toBe(true) + const probeUrl = requestUrl(1) + expect(probeUrl.origin + probeUrl.pathname).toBe( + 'https://sentry.io/api/0/organizations/acme/issues/' + ) + expect(probeUrl.searchParams.get('statsPeriod')).toBeNull() + }) +}) diff --git a/apps/sim/connectors/sentry/sentry.ts b/apps/sim/connectors/sentry/sentry.ts index 4ea805ff9a2..8f1069af1f0 100644 --- a/apps/sim/connectors/sentry/sentry.ts +++ b/apps/sim/connectors/sentry/sentry.ts @@ -417,11 +417,19 @@ export const sentryConnector: ConnectorConfig = { * and latest-event fetches already use organization-scoped paths, so the whole * connector now speaks one path style. * - * Consequence of the migration: this endpoint always resolves a date range, and - * with no `statsPeriod`/`start`/`end` it defaults to the widest range it accepts - * (90 days). Issues last seen before that window are absent from the listing and - * are reconciled away, which is the same "aged out of the query window" semantic - * the default query already documents. + * Listing coverage across the migration is unchanged. Both endpoints bottom out in + * the same issue-search executor, which floors the query start at + * `max(retention_window_start, now - timedelta(days=90))` regardless of what date + * range the request carries, so the project endpoint's `date_from=None` produced the + * same 90-day floor this one inherits from its own default. Both list exactly the + * issues Sentry's issue search can reach, and neither can reach an issue last seen + * longer ago than that. + * + * So an issue absent from this listing is absent from Sentry's own issue search + * under the same query/environment — a genuine scope exit, exactly like an issue + * that stopped matching `is:unresolved`. The listing is authoritative and deletion + * reconciliation is allowed to run; `listingCapped` below is reserved for the one + * condition that genuinely truncates it, `maxIssues`. */ const url = new URL(`${apiBase}/organizations/${encodeURIComponent(organization)}/issues/`) url.searchParams.set('project', project) diff --git a/apps/sim/connectors/webflow/webflow.test.ts b/apps/sim/connectors/webflow/webflow.test.ts index 15468d0babc..3b757e019ec 100644 --- a/apps/sim/connectors/webflow/webflow.test.ts +++ b/apps/sim/connectors/webflow/webflow.test.ts @@ -112,6 +112,24 @@ describe('webflow listDocuments deletion-reconciliation guards', () => { expect(syncContext.listingCapped).toBe(true) }) + it('leaves listingCapped unset when a well-formed page exhausts the collection', async () => { + mockNameThenItems({ items: [itemFixture('a'), itemFixture('b')], pagination: { total: 2 } }) + + const syncContext: Record = {} + await webflowConnector.listDocuments(ACCESS_TOKEN, CONFIG, undefined, syncContext) + + expect(syncContext.listingCapped).toBeUndefined() + }) + + it('leaves listingCapped unset when a well-formed page reports an empty collection', async () => { + mockNameThenItems({ items: [], pagination: { total: 0 } }) + + const syncContext: Record = {} + await webflowConnector.listDocuments(ACCESS_TOKEN, CONFIG, undefined, syncContext) + + expect(syncContext.listingCapped).toBeUndefined() + }) + /** * Without a usable `pagination.total` the offset math cannot tell a full page * apart from the last one, so treating it as exhausted would feed every unread @@ -127,12 +145,169 @@ describe('webflow listDocuments deletion-reconciliation guards', () => { expect(syncContext.listingCapped).toBe(true) }) - it('leaves listingCapped unset on a short page with no usable total', async () => { + /** + * A short page is the same unknowable state as a full one: the fallback total + * collapses to the rows in hand, so the collection ends here whether or not + * rows remain. `total` is documented optional, so its absence proves nothing + * either way — and "we cannot rule out unread rows" is the fail-safe reading. + */ + it('flags listingCapped on a short page whose envelope carries no usable total', async () => { mockNameThenItems({ items: [itemFixture('a')] }) const syncContext: Record = {} await webflowConnector.listDocuments(ACCESS_TOKEN, CONFIG, undefined, syncContext) + expect(syncContext.listingCapped).toBe(true) + }) + + it('flags listingCapped when the envelope has no pagination object at all', async () => { + mockNameThenItems({ items: [itemFixture('a'), itemFixture('b')] }) + + const syncContext: Record = {} + await webflowConnector.listDocuments(ACCESS_TOKEN, CONFIG, undefined, syncContext) + + expect(syncContext.listingCapped).toBe(true) + }) + + /** + * `Number(null)`, `Number('')`, `Number([])`, and `Number(false)` are all a + * finite `0`, so coercing the reported total reads a malformed envelope as + * "this collection holds zero rows" — the listing reports itself complete + * while holding rows, and reconciliation deletes every one of them. + */ + it.each([ + ['null', null], + ['an empty string', ''], + ['an empty array', []], + ['false', false], + ['a negative count', -1], + ['a fractional count', 2.5], + ['a numeric string', '2'], + ])('flags listingCapped when pagination.total is %s', async (_label, total) => { + mockNameThenItems({ items: [itemFixture('a'), itemFixture('b')], pagination: { total } }) + + const syncContext: Record = {} + const result = await webflowConnector.listDocuments( + ACCESS_TOKEN, + CONFIG, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(2) + expect(syncContext.listingCapped).toBe(true) + }) + + it('flags listingCapped when pagination is present but carries no usable total', async () => { + mockNameThenItems({ items: [itemFixture('a')], pagination: { limit: 100, offset: 0 } }) + + const syncContext: Record = {} + await webflowConnector.listDocuments(ACCESS_TOKEN, CONFIG, undefined, syncContext) + + expect(syncContext.listingCapped).toBe(true) + }) + + /** + * The production shape: a malformed 200 empties a mid-list collection, the + * walk advances to the next collection, and the whole run is reported as a + * clean full listing. The skipped collection's documents are neither empty + * nor below the collapse ratio, so no sync-engine backstop catches it. + */ + it('flags listingCapped on an empty page with no pagination and still advances', async () => { + mockNameThenItems({ items: [] }) + + const syncContext: Record = {} + const result = await webflowConnector.listDocuments( + ACCESS_TOKEN, + { siteId: 'site-1', collectionId: 'col-1,col-2' }, + undefined, + syncContext + ) + + expect(result.documents).toEqual([]) + expect(syncContext.listingCapped).toBe(true) + expect(result.hasMore).toBe(true) + }) +}) + +/** + * With no collection ids configured the run's whole scope comes from + * `GET /sites/{id}/collections`. + */ +describe('webflow collection-scope resolution', () => { + const SITE_ONLY = { siteId: 'site-1' } + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + /** + * An empty scope lists nothing without claiming truncation: `listedCount = 0` + * is exactly the shape the sync engine's own `'empty'` backstop classifies as + * suspect and blocks on the first sync, which still reconciles once a + * consecutive sync corroborates it. Setting `listingCapped` here would + * short-circuit that two-strike path and block reconciliation forever. + */ + it.each([ + ['the envelope carries no collections key', {}], + ['collections is null', { collections: null }], + ['collections is empty', { collections: [] }], + ])('lists nothing without flagging listingCapped when %s', async (_label, body) => { + mockFetch.mockResolvedValueOnce(jsonResponse(body)) + + const syncContext: Record = {} + const result = await webflowConnector.listDocuments( + ACCESS_TOKEN, + SITE_ONLY, + undefined, + syncContext + ) + + expect(result.documents).toEqual([]) + expect(result.hasMore).toBe(false) + expect(syncContext.listingCapped).toBeUndefined() + }) + + /** + * A non-array `collections` fails the sync loudly rather than syncing an empty + * scope. The rejection is pinned to a `TypeError` — the spec-mandated failure + * for a `for...of` over a non-iterable — rather than to the engine's wording, + * and to the point of failure: the collection listing is the only request made + * and nothing is written back to the sync context. + */ + it('throws when collections is not an array', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ collections: { id: 'col-1' } })) + + const syncContext: Record = {} + await expect( + webflowConnector.listDocuments(ACCESS_TOKEN, SITE_ONLY, undefined, syncContext) + ).rejects.toThrow(TypeError) + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(syncContext.collectionNames).toBeUndefined() + expect(syncContext.listingCapped).toBeUndefined() + }) + + it('leaves listingCapped unset when the site listing and its page are both well formed', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ collections: [{ id: 'col-1', displayName: 'Posts' }] })) + .mockResolvedValueOnce(jsonResponse({ items: [itemFixture('a')], pagination: { total: 1 } })) + + const syncContext: Record = {} + const result = await webflowConnector.listDocuments( + ACCESS_TOKEN, + SITE_ONLY, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(1) + expect(result.hasMore).toBe(false) expect(syncContext.listingCapped).toBeUndefined() }) }) diff --git a/apps/sim/connectors/webflow/webflow.ts b/apps/sim/connectors/webflow/webflow.ts index 3a716a380bc..fe96c3a6eff 100644 --- a/apps/sim/connectors/webflow/webflow.ts +++ b/apps/sim/connectors/webflow/webflow.ts @@ -175,7 +175,7 @@ export const webflowConnector: ConnectorConfig = { const data = (await response.json()) as { items?: WebflowItem[] - pagination?: { total?: number } + pagination?: { total?: unknown } } const rawItems = data.items || [] @@ -199,10 +199,17 @@ export const webflowConnector: ConnectorConfig = { * math into `NaN` (which would silently end the collection mid-listing). The * offset advances by the rows actually returned rather than the echoed * `pagination.limit`, so a short page can never skip rows. + * + * The value is type-checked rather than coerced: `Number(null)`, + * `Number('')`, `Number([])`, and `Number(false)` all yield a finite `0`, so + * coercion would read a `null` total as "this collection holds zero rows" + * and hand every unread row to deletion reconciliation. A negative or + * fractional total is malformed for the same purpose, so only a + * non-negative integer counts as known. */ - const reportedTotal = Number(data.pagination?.total) - const totalKnown = Number.isFinite(reportedTotal) - const total = totalKnown ? reportedTotal : rawItems.length + const reportedTotal = data.pagination?.total + const totalKnown = Number.isInteger(reportedTotal) && (reportedTotal as number) >= 0 + const total = totalKnown ? (reportedTotal as number) : rawItems.length const advance = rawItems.length const hasMoreInCollection = advance > 0 && cursorState.offset + advance < total @@ -217,12 +224,17 @@ export const webflowConnector: ConnectorConfig = { const stalledMidCollection = advance === 0 && cursorState.offset < total /** - * A full page with no usable `pagination.total` to page against. The fallback - * total collapses to the row count, which ends the collection right here, so - * whether rows remain is unknowable — and guessing "exhausted" would hand - * every unread row to deletion reconciliation. + * No usable `pagination.total` to page against, on a page of any size. The + * fallback total collapses to the rows in hand, which ends the collection + * right here and makes `stalledMidCollection` (`offset < 0`) unreachable, so + * a short or empty page looks exactly like exhaustion. The Data API v2 + * schema marks `pagination` required but `total` optional, so its absence is + * not proof of anything either way — and between "the collection is drained" + * and "we stopped for a reason we cannot rule out", only the latter is + * fail-safe: guessing "exhausted" would hand every unread row to deletion + * reconciliation. */ - const unknownTotalOnFullPage = !totalKnown && advance >= pageSize + const unknownTotal = !totalKnown /** * A truncated listing must skip deletion reconciliation, or still-existing @@ -234,7 +246,7 @@ export const webflowConnector: ConnectorConfig = { if ( syncContext && (stalledMidCollection || - unknownTotalOnFullPage || + unknownTotal || (hitMaxItems && (hasMoreInCollection || hasMoreCollections))) ) { syncContext.listingCapped = true diff --git a/apps/sim/connectors/zendesk/zendesk.test.ts b/apps/sim/connectors/zendesk/zendesk.test.ts index d4cb60dd8d1..93326648570 100644 --- a/apps/sim/connectors/zendesk/zendesk.test.ts +++ b/apps/sim/connectors/zendesk/zendesk.test.ts @@ -196,6 +196,279 @@ describe('zendeskConnector.listDocuments ticket capping', () => { }) }) +/** + * `has_more: true` with a `links.next` the same-origin guard refuses — the shape + * a host-mapped Help Center or brand host produces. The guard is correct and + * must keep rejecting the link; what must not happen is the walk reporting the + * partial listing it managed to read as a complete one, because the sync engine + * then hard-deletes every stored document past the last page read. + */ +describe('zendeskConnector.listDocuments unfollowable continuation links', () => { + beforeEach(() => { + mockSecureFetch.mockReset() + }) + + const rejectedLinks: Array<[string, unknown]> = [ + ['a brand host', 'https://support.acme.com/api/v2/tickets.json?page%5Bafter%5D=x'], + ['a host-prefix lookalike', 'https://acme.zendesk.com.evil.com/api/v2/tickets.json'], + ['a foreign host', 'https://evil.com/api/v2/tickets.json'], + ['a missing link', undefined], + ['a non-string link', 42], + ] + + it.each(rejectedLinks)( + 'caps the ticket listing when has_more is true but next is %s', + async (_label, next) => { + mockApi(() => ({ + tickets: [ticket(1), ticket(2), ticket(3)], + meta: { has_more: true }, + links: { next }, + })) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, maxTickets: '800' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(3) + expect(syncContext.listingCapped).toBe(true) + } + ) + + it('caps the article listing when has_more is true but next is cross-origin', async () => { + mockApi(() => ({ + articles: [ + { + id: 1, + title: 'A', + body: '

hello

', + html_url: `${BASE}/hc/articles/1`, + section_id: null, + label_names: [], + author_id: 1, + locale: 'en-us', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-02-01T00:00:00Z', + edited_at: '2024-02-01T00:00:00Z', + draft: false, + }, + ], + meta: { has_more: true }, + links: { next: 'https://support.acme.com/api/v2/help_center/articles.json?page=2' }, + })) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, contentType: 'articles' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(1) + expect(syncContext.listingCapped).toBe(true) + }) + + it('caps the ticket search when next_page is cross-origin', async () => { + mockApi(() => ({ + results: [ticket(1)], + next_page: 'https://evil.com/api/v2/search.json?page=2', + })) + + const syncContext: Record = {} + await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, ticketStatus: 'open', maxTickets: '800' }, + undefined, + syncContext + ) + + expect(syncContext.listingCapped).toBe(true) + }) + + it('leaves listingCapped unset when the search drains its pages honestly', async () => { + mockApi(() => ({ results: [ticket(1)], count: 1, next_page: null })) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, ticketStatus: 'open', maxTickets: '800' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(1) + expect(syncContext.listingCapped).toBeUndefined() + }) +}) + +/** + * Every cursor-paginated Zendesk response carries the `meta` envelope, so a + * missing or malformed one is a malformed 200 rather than a drained cursor. + * Reading it as exhaustion is the same asymmetry the Webflow listing already + * eliminated: the walk stops, the listing reports itself complete, and the sync + * engine hard-deletes every document past the last page read. The walk still + * stops — `meta.has_more === true` is the only signal that continues it, since + * Zendesk emits `links.next` even on the last page and following it on a + * meta-less response would never terminate — but it stops as truncated. + */ +describe('zendeskConnector.listDocuments malformed pagination envelopes', () => { + beforeEach(() => { + mockSecureFetch.mockReset() + }) + + it.each([ + ['no meta envelope', undefined], + ['an empty meta envelope', {}], + ['a stringified has_more', { has_more: 'true' }], + ])( + 'stops the walk and caps when the response carries %s despite a next link', + async (_label, meta) => { + let page = 0 + const urls = mockApi(() => { + page += 1 + return { + tickets: [ticket(page)], + ...(meta === undefined ? {} : { meta }), + links: { next: `${BASE}/api/v2/tickets.json?page%5Bafter%5D=${page}` }, + } + }) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, maxTickets: '2' }, + undefined, + syncContext + ) + + expect(urls).toHaveLength(1) + expect(result.documents.map((d) => d.externalId)).toEqual(['ticket-1']) + expect(syncContext.listingCapped).toBe(true) + } + ) + + it('caps the ticket listing on a bare 200 interstitial', async () => { + mockApi(() => ({})) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, maxTickets: '800' }, + undefined, + syncContext + ) + + expect(result.documents).toEqual([]) + expect(syncContext.listingCapped).toBe(true) + }) + + it('caps the article listing on a bare 200 interstitial', async () => { + mockApi(() => ({})) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, contentType: 'articles' }, + undefined, + syncContext + ) + + expect(result.documents).toEqual([]) + expect(syncContext.listingCapped).toBe(true) + }) + + /** + * An absent `next_page` ends the search walk. The `count`-vs-returned check + * is what catches a search that still had matches, so a missing key can only + * lose records the count does not already account for. + */ + it('does not cap the ticket search when next_page is absent and count agrees', async () => { + mockApi(() => ({ results: [ticket(1)], count: 1 })) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, ticketStatus: 'open', maxTickets: '800' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(1) + expect(syncContext.listingCapped).toBeUndefined() + }) + + it('caps the ticket search when count reports more matches than were returned', async () => { + mockApi(() => ({ results: [ticket(1)], count: 9 })) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, ticketStatus: 'open', maxTickets: '800' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(1) + expect(syncContext.listingCapped).toBe(true) + }) + + it('leaves listingCapped unset when the ticket cursor drains to has_more false', async () => { + mockApi(() => ({ + tickets: [ticket(1)], + meta: { has_more: false }, + links: { next: `${BASE}/api/v2/tickets.json?page%5Bafter%5D=x` }, + })) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, maxTickets: '800' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(1) + expect(syncContext.listingCapped).toBeUndefined() + }) + + it('leaves listingCapped unset when the article cursor drains to has_more false', async () => { + mockApi(() => ({ + articles: [ + { + id: 1, + title: 'A', + body: '

hello

', + html_url: `${BASE}/hc/articles/1`, + section_id: null, + label_names: [], + author_id: 1, + locale: 'en-us', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-02-01T00:00:00Z', + edited_at: '2024-02-01T00:00:00Z', + draft: false, + }, + ], + meta: { has_more: false }, + })) + + const syncContext: Record = {} + const result = await zendeskConnector.listDocuments( + 'tok', + { ...CONFIG, contentType: 'articles' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(1) + expect(syncContext.listingCapped).toBeUndefined() + }) +}) + describe('zendeskConnector ticket contentHash invariant', () => { beforeEach(() => { mockSecureFetch.mockReset() diff --git a/apps/sim/connectors/zendesk/zendesk.ts b/apps/sim/connectors/zendesk/zendesk.ts index 8ea3d6b89c1..b8381ca574a 100644 --- a/apps/sim/connectors/zendesk/zendesk.ts +++ b/apps/sim/connectors/zendesk/zendesk.ts @@ -151,24 +151,82 @@ async function zendeskApiGet( return (await response.json()) as Record } +/** + * One step of a paginated walk. + * + * `url === null` alone does not mean the listing ended: `truncated` separates a + * source that reported no further records from one that still advertised records + * but whose continuation link could not be followed. Collapsing the two into a + * bare `null` reports a partial listing as a complete one, and the sync engine + * then hard-deletes every stored document past the last page it managed to read. + */ +interface PageStep { + /** The next page to request, or null when the walk cannot continue. */ + url: string | null + /** + * True when the walk stopped without the source ever saying it was done: the + * response still advertised more records, but its continuation link was + * missing, malformed, or rejected by the same-origin guard. + */ + truncated: boolean +} + +/** A walk that ended because the source said there was nothing left. */ +const PAGE_WALK_EXHAUSTED: PageStep = { url: null, truncated: false } + /** * Reads the cursor-pagination continuation from a Zendesk response. Offset * pagination is limited to the first 100 pages / 10,000 records and answers 400 * past that depth, so every unbounded listing uses cursor pagination instead. + * + * `meta.has_more === true` is the only signal that continues the walk. Zendesk + * emits `links.next` even on the last page, so `meta` — not the link — is what + * decides whether to keep paginating; following a link on a response with no + * `meta` would never terminate, and this walk has no page-depth valve. + * + * How the stop is *reported* is what distinguishes the two ways it can happen: + * `has_more === false` is exhaustion, while a missing or malformed `meta` (an + * interstitial, a truncated body, a proxy error page) is truncation. Reading the + * latter as a drained cursor hands every unlisted document to deletion + * reconciliation. + * + * A `links.next` the same-origin guard refuses — a host-mapped Help Center or + * brand host emits one that is not on `https://{subdomain}.zendesk.com` — also + * stops the walk as truncated, never as exhausted. The guard itself is correct + * and stays; only the reason the walk stopped is reported honestly. * @see https://developer.zendesk.com/api-reference/introduction/pagination/ */ -function readCursorNext(data: Record, baseUrl: string): string | null { - const meta = data.meta as { has_more?: boolean } | undefined - if (meta?.has_more !== true) return null +function readCursorNext(data: Record, baseUrl: string): PageStep { + const meta = data.meta as { has_more?: unknown } | undefined + if (meta?.has_more !== true) return { url: null, truncated: meta?.has_more !== false } const links = data.links as { next?: string } | undefined - return sameOriginNextUrl(links?.next, baseUrl) + const url = sameOriginNextUrl(links?.next, baseUrl) + return { url, truncated: url === null } +} + +/** + * Reads the offset-pagination continuation used by the Search API, which carries + * `next_page` (null on the last page) instead of a `meta`/`links` envelope. + * + * An absent `next_page` is exhaustion. Search responses carry both keys, and the + * caller's `count`-vs-returned check already caps every case where a missing + * `next_page` could hide records, so treating its absence as truncation would + * only add a cap nothing needs. A present link the same-origin guard refuses is + * truncation. + */ +function readOffsetNext(data: Record, baseUrl: string): PageStep { + const nextPage = data.next_page + if (nextPage == null) return PAGE_WALK_EXHAUSTED + const url = sameOriginNextUrl(nextPage, baseUrl) + return { url, truncated: url === null } } /** * Fetches Help Center articles via cursor pagination. * - * `capped` is true only if the page safety valve trips while more articles - * remain — a fully drained cursor is a complete listing. + * `capped` is true only when the walk stopped with articles still remaining — + * the page safety valve tripping, or a continuation link that could not be + * followed. A cursor drained to `has_more: false` is a complete listing. */ async function fetchArticles( baseUrl: string, @@ -187,7 +245,16 @@ async function fetchArticles( items.push(...((data.articles as ZendeskArticle[]) || [])) pages += 1 - url = readCursorNext(data, baseUrl) + const step = readCursorNext(data, baseUrl) + if (step.truncated) { + logger.warn( + 'Zendesk article listing stopped at an unusable continuation link with more articles remaining; listing is incomplete.', + { pages, articles: items.length } + ) + return { items, capped: true } + } + + url = step.url if (url && pages >= MAX_ARTICLE_PAGES) { logger.warn( `Zendesk article listing stopped at the ${MAX_ARTICLE_PAGES}-page safety valve with more articles remaining; listing is incomplete.` @@ -246,17 +313,33 @@ async function fetchTicketsViaCursor( const params = new URLSearchParams({ 'page[size]': String(PAGE_SIZE), sort: '-updated_at' }) let url: string | null = `${baseUrl}/api/v2/tickets.json?${params}` let sourceHasMore = false + let truncated = false while (url) { const data = await zendeskApiGet(url, accessToken, sourceConfig) items.push(...((data.tickets as ZendeskTicket[]) || [])) - url = readCursorNext(data, baseUrl) + const step = readCursorNext(data, baseUrl) + if (step.truncated) { + logger.warn( + 'Zendesk ticket listing stopped at an unusable continuation link with more tickets remaining; listing is incomplete.', + { tickets: items.length } + ) + truncated = true + break + } + + url = step.url sourceHasMore = url !== null if (items.length >= limit) break } - const capped = items.length > limit || (items.length >= limit && sourceHasMore) + /** + * `truncated` caps regardless of how few tickets came back: the walk stopped + * short of a source that still had records, so the unread remainder must not + * be read as deleted. + */ + const capped = truncated || items.length > limit || (items.length >= limit && sourceHasMore) return { items: items.slice(0, limit), capped } } @@ -283,6 +366,7 @@ async function fetchTicketsViaSearch( }) let url: string | null = `${baseUrl}/api/v2/search.json?${params}` let sourceHasMore = false + let truncated = false let totalMatches: number | null = null while (url) { @@ -290,13 +374,24 @@ async function fetchTicketsViaSearch( items.push(...((data.results as ZendeskTicket[]) || [])) if (typeof data.count === 'number') totalMatches = data.count - url = sameOriginNextUrl(data.next_page, baseUrl) + const step = readOffsetNext(data, baseUrl) + if (step.truncated) { + logger.warn( + 'Zendesk ticket search stopped at an unusable continuation link with more results remaining; listing is incomplete.', + { tickets: items.length } + ) + truncated = true + break + } + + url = step.url sourceHasMore = url !== null if (items.length >= effectiveLimit) break } const returned = Math.min(items.length, effectiveLimit) const capped = + truncated || sourceHasMore || items.length > effectiveLimit || (totalMatches !== null && totalMatches > returned) @@ -324,7 +419,16 @@ async function fetchTicketComments( allComments.push(...((data.comments as ZendeskComment[]) || [])) pages += 1 - url = readCursorNext(data, baseUrl) + const step = readCursorNext(data, baseUrl) + if (step.truncated) { + logger.warn('Zendesk ticket comment listing stopped at an unusable continuation link', { + ticketId, + comments: allComments.length, + }) + break + } + + url = step.url if (url && pages >= MAX_COMMENT_PAGES) { logger.warn('Zendesk ticket comment listing truncated at the page safety valve', { ticketId, diff --git a/apps/sim/content/library/automation-anywhere-alternative/index.mdx b/apps/sim/content/library/automation-anywhere-alternative/index.mdx index bc81db79df6..fc75f8b195d 100644 --- a/apps/sim/content/library/automation-anywhere-alternative/index.mdx +++ b/apps/sim/content/library/automation-anywhere-alternative/index.mdx @@ -17,7 +17,7 @@ faq: - q: "Can Sim replace Automation Anywhere bots entirely?" a: "Sometimes, but that should not be the default goal. Sim can replace workflows that primarily interpret documents, messages, and changing requests before acting through integrations, APIs, or MCP tools. Automation Anywhere remains a stronger fit for stable desktop automation across legacy systems, especially inside an existing RPA program. A hybrid workflow can use Sim for interpretation and Automation Anywhere for the final UI-driven action." - q: "Does Sim require coding?" - a: "No. You can build through Mothership in natural language or use the visual canvas. Technical users can add functions, call APIs, and expose workflows as services when the process needs custom behavior. You can begin visually and add code only where it earns its place." + a: "No. Describe what you want to Sim in Chat, or build it in the visual builder. Technical users can add functions, call APIs, and expose workflows as services when the process needs custom behavior. You can begin visually and add code only where it earns its place." - q: "Does Automation Anywhere have AI agents?" a: "Yes. Automation 360 includes AI Agent Studio, Document Automation, Automation Co-Pilot, and the Process Reasoning Engine. Sim is not differentiated by merely having AI. Its difference is an agent-first workflow graph, an Apache 2.0 core, public entry pricing, and deployment as APIs, chat experiences, or MCP tools." - q: "What does Sim cost compared with Automation Anywhere?" @@ -53,7 +53,7 @@ Automation Anywhere and Sim can both combine AI with automation, but they make y | Comparison | Automation Anywhere | Sim | | --- | --- | --- | | Core model | [Automation 360 combines automation, agents, and document processing](https://www.automationanywhere.com/products/automation-360). | Sim is an open-source workspace for building agent workflows with deterministic controls. | -| Builder | [Automation Workspace](https://www.automationanywhere.com/products/automation-workspace) and Bot Creator tooling author automations managed through Control Room. | You build through Mothership, the visual canvas, or the API. | +| Builder | [Automation Workspace](https://www.automationanywhere.com/products/automation-workspace) and Bot Creator tooling author automations managed through Control Room. | You build in Chat, the visual builder, or the API. | | Runtime | [Attended and unattended automation runs on devices connected to Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html). | Workflows run in Sim Cloud or on infrastructure you control. | | Reasoning | [AI Agent Studio](https://www.automationanywhere.com/products/ai-agent-studio) and the [Process Reasoning Engine](https://www.automationanywhere.com/products/process-reasoning-engine) add goal-driven agents to the platform. | Agent blocks interpret variable input directly inside the workflow graph. | | Document work | [Document Automation extracts and processes data from business documents](https://www.automationanywhere.com/products/document-automation). | Agent blocks can interpret documents, retrieve knowledge, and return structured output for later blocks. | diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 89d31c2fac4..8b42946bdbf 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -159,13 +159,6 @@ export const shopifyCallbackQuerySchema = z.object({ shop: z.string().optional(), }) -export const shopifyStoreCookieSchema = z.object({ - accessToken: z.string().min(1), - shopDomain: z.string().min(1), - scope: z.string().optional(), - returnUrl: z.string().optional(), -}) - const SHOPIFY_SHOP_DOMAIN_REGEX = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\.myshopify\.com$/ export const shopifyShopDomainSchema = z.string().regex(SHOPIFY_SHOP_DOMAIN_REGEX) diff --git a/apps/sim/lib/credentials/deletion.test.ts b/apps/sim/lib/credentials/deletion.test.ts new file mode 100644 index 00000000000..1a175f2b3e9 --- /dev/null +++ b/apps/sim/lib/credentials/deletion.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + * + * Coverage for {@link deleteOrphanedOAuthAccount}, the single predicate standing + * between a workspace-scoped admin disconnect and a cross-workspace OAuth grant + * wipe. `credential.accountId` is `ON DELETE CASCADE`, so a guard that stops + * matching takes every other workspace's credential row down with the grant. + * + * Every other suite mocks this function out (`orchestration/index.test.ts`, + * `__tests__/service-account.test.ts`) and only asserts that it is *called*, so + * dropping the `notExists` clause would leave the whole suite green. These tests + * therefore run the real query builder and assert on the statement Postgres + * receives: `drizzle-orm` and `@sim/db/schema` are un-mocked here (the global + * mocks in `vitest.setup.ts` replace the operators with plain object literals, + * which cannot express a subquery), and `@sim/db` is a `drizzle-orm/pg-proxy` + * client whose driver captures the compiled statement and replays the rows + * Postgres would return for the scenario under test. + */ +import { drizzle } from 'drizzle-orm/pg-proxy' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { capturedQueries, driverRows, mockLogger } = vi.hoisted(() => ({ + capturedQueries: [] as { sql: string; params: unknown[] }[], + driverRows: { value: [] as unknown[] }, + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})) + +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') + +vi.mock('@sim/logger', () => ({ createLogger: () => mockLogger })) + +vi.mock('@sim/db', () => ({ + db: drizzle(async (sql: string, params: unknown[]) => { + capturedQueries.push({ sql, params }) + return { rows: driverRows.value } + }), +})) + +import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion' + +const ACCOUNT_ID = 'acct-bob-google' + +/** Collapses whitespace so assertions read against a stable, single-line statement. */ +function normalizeSql(sql: string): string { + return sql.replace(/\s+/g, ' ').trim() +} + +function onlyQuery(): { sql: string; params: unknown[] } { + expect(capturedQueries).toHaveLength(1) + const query = capturedQueries[0] + return { sql: normalizeSql(query.sql), params: query.params } +} + +/** The `not exists (...)` guard body, i.e. everything the subquery constrains on. */ +function guardSubquery(sql: string): string { + const match = /not exists \((.*)\)/.exec(sql) + if (!match) throw new Error(`statement has no "not exists" reference guard: ${sql}`) + return match[1] +} + +describe('deleteOrphanedOAuthAccount', () => { + beforeEach(() => { + capturedQueries.length = 0 + driverRows.value = [] + vi.clearAllMocks() + }) + + it('guards the account delete with a reference check against the credential table', async () => { + await deleteOrphanedOAuthAccount(ACCOUNT_ID) + + const { sql, params } = onlyQuery() + + expect(sql).toContain('delete from "account"') + expect(sql).toContain('"account"."id" = $1') + expect(sql).toContain('returning "id"') + + const subquery = guardSubquery(sql) + expect(subquery).toContain('from "credential"') + expect(subquery).toContain('"credential"."account_id" = $2') + expect(subquery).not.toContain('workspace_id') + + expect(params).toEqual([ACCOUNT_ID, ACCOUNT_ID]) + }) + + it('keys the reference check on account_id alone and reports nothing when it matches no row', async () => { + /** + * The row matching itself is Postgres's, not this harness's — the driver + * replays the empty RETURNING that a surviving workspace-B `credential` row + * would produce, and the statement carries what makes that row visible: the + * subquery is keyed on `account_id` alone. A `workspace_id` filter would hide + * every other workspace's reference and turn an intra-workspace admin + * disconnect into a cross-workspace grant wipe. + */ + driverRows.value = [] + + await deleteOrphanedOAuthAccount(ACCOUNT_ID) + + expect(guardSubquery(onlyQuery().sql)).not.toContain('workspace_id') + expect(mockLogger.info).not.toHaveBeenCalled() + }) + + it('deletes a genuinely orphaned account', async () => { + driverRows.value = [{ id: ACCOUNT_ID }] + + await deleteOrphanedOAuthAccount(ACCOUNT_ID) + + expect(onlyQuery().sql).toContain('returning "id"') + expect(mockLogger.info).toHaveBeenCalledWith('Deleted orphaned OAuth account', { + accountId: ACCOUNT_ID, + }) + }) + + it('does not scope the account delete by owner, so an admin can disconnect a teammate grant', async () => { + /** + * PR #6737 exists so a workspace admin can disconnect another member's OAuth + * credential. An `account.user_id = ` predicate would fail that case + * closed and strand a live grant nothing can reap, so the reference count — + * not ownership — is deliberately the only guard. A legacy reference that + * addresses the grant by raw `account.id` is covered by the same count + * WHENEVER a `workflowId` pins the workspace: `authorizeCredentialUseForAuth` + * then resolves it only through a `credential` row in that workspace, and any + * such row keeps `not exists` false. It is not covered when `scopeWorkspaceId` + * is null — that path falls through to an owner-only lookup that reads + * `account` directly (`lib/auth/credential-access.ts`), which no `credential` + * row backs and this count therefore cannot see. + */ + await deleteOrphanedOAuthAccount(ACCOUNT_ID) + + const { sql } = onlyQuery() + expect(sql).not.toContain('user_id') + expect(sql).not.toContain('provider_id') + }) +}) diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index f2b8d866c29..3fca53bcd10 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -109,9 +109,14 @@ export async function deleteConnectionCredential( * Scoped by `accountId`, not by owner — the caller is already authorized * against the credential, which may belong to another user. * - * The reference check is a predicate on the delete: `credential.accountId` is - * `ON DELETE CASCADE`, so a credential racing a separate check would be reaped - * by Postgres without {@link clearCredentialRefs} ever running. + * The reference check is a predicate on the delete rather than a separate + * SELECT, which narrows the race from check-then-act down to a single + * statement — it does not close it. The caller issues the credential delete and + * this account delete as two statements (`orchestration/index.ts`), so under + * READ COMMITTED a `credential` row another workspace commits after this + * statement takes its snapshot is invisible here and is then reaped by + * `credential.accountId ON DELETE CASCADE` without {@link clearCredentialRefs} + * ever running. The window is narrow, but a hit is silent data loss. */ export async function deleteOrphanedOAuthAccount(accountId: string): Promise { const deleted = await db diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index 2241e189fba..6b2f7c5b2f9 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -65,16 +65,59 @@ describe('_removed_ prefix invariant', () => { describe('migration targets', () => { it('every rename points at a subblock that still exists', () => { const offenders: string[] = [] - for (const [blockType, renames] of Object.entries(SUBBLOCK_ID_MIGRATIONS)) { + for (const [blockType, migrations] of Object.entries(SUBBLOCK_ID_MIGRATIONS)) { const config = getAllBlocks().find((block) => block.type === blockType) if (!config) { offenders.push(`${blockType} (block not registered)`) continue } const liveIds = new Set((config.subBlocks ?? []).map((subBlock) => subBlock.id)) - for (const [legacyId, currentId] of Object.entries(renames)) { - if (currentId.startsWith('_removed_')) continue - if (!liveIds.has(currentId)) offenders.push(`${blockType}.${legacyId} -> ${currentId}`) + for (const { from, to } of migrations) { + if (to.startsWith('_removed_')) continue + if (!liveIds.has(to)) offenders.push(`${blockType}.${from} -> ${to}`) + } + } + expect(offenders).toEqual([]) + }) + + /** + * A scope naming an operation the block cannot select never fires, so the + * legacy value it was added to rescue stays stranded — a silent no-op that + * reads as a shipped fix. + */ + it('every operation scope names an operation the block offers', () => { + const offenders: string[] = [] + for (const [blockType, migrations] of Object.entries(SUBBLOCK_ID_MIGRATIONS)) { + const config = getAllBlocks().find((block) => block.type === blockType) + const operationConfig = config?.subBlocks?.find((subBlock) => subBlock.id === 'operation') + const offered = new Set( + (Array.isArray(operationConfig?.options) ? operationConfig.options : []).map((option) => + typeof option === 'string' ? option : ((option as { id?: string }).id ?? '') + ) + ) + for (const { from, to, whenOperation } of migrations) { + for (const operation of whenOperation ?? []) { + if (!offered.has(operation)) + offenders.push(`${blockType}.${from} -> ${to} @ ${operation}`) + } + } + } + expect(offenders).toEqual([]) + }) + + /** + * An unconditional rename off an id that is still a live control steals that + * control's value on every load. Every such rename must be operation-scoped. + */ + it('never renames off an id that is still a live control, unscoped', () => { + const offenders: string[] = [] + for (const [blockType, migrations] of Object.entries(SUBBLOCK_ID_MIGRATIONS)) { + const config = getAllBlocks().find((block) => block.type === blockType) + if (!config) continue + const liveIds = new Set((config.subBlocks ?? []).map((subBlock) => subBlock.id)) + for (const { from, to, whenOperation } of migrations) { + if (whenOperation) continue + if (liveIds.has(from)) offenders.push(`${blockType}.${from} -> ${to}`) } } expect(offenders).toEqual([]) @@ -117,9 +160,9 @@ describe('migrateSubblockIds', () => { // Every legacy id in the map, so a rename added later without a // matching assertion still fails here. ...Object.fromEntries( - Object.keys(SUBBLOCK_ID_MIGRATIONS.snowflake).map((legacyId) => [ - legacyId, - { id: legacyId, type: 'short-input', value: `value-${legacyId}` }, + SUBBLOCK_ID_MIGRATIONS.snowflake.map(({ from }) => [ + from, + { id: from, type: 'short-input', value: `value-${from}` }, ]) ), }, @@ -131,12 +174,10 @@ describe('migrateSubblockIds', () => { expect(migrated).toBe(true) // The advanced text members, not the pickers: a migrated block has no // credential yet, so a picker could not hydrate the stored name. - for (const [legacyId, currentId] of Object.entries(SUBBLOCK_ID_MIGRATIONS.snowflake)) { - if (currentId.startsWith('_removed_')) continue - expect(blocks.b1.subBlocks[currentId]?.value, `${legacyId} -> ${currentId}`).toBe( - `value-${legacyId}` - ) - expect(blocks.b1.subBlocks[legacyId], legacyId).toBeUndefined() + for (const { from, to } of SUBBLOCK_ID_MIGRATIONS.snowflake) { + if (to.startsWith('_removed_')) continue + expect(blocks.b1.subBlocks[to]?.value, `${from} -> ${to}`).toBe(`value-${from}`) + expect(blocks.b1.subBlocks[from], from).toBeUndefined() } }) @@ -488,6 +529,211 @@ describe('migrateSubblockIds', () => { }) }) + describe('servicenow block', () => { + it('moves a legacy Read Records projection onto readFields', () => { + const input: Record = { + b1: makeBlock({ + type: 'servicenow', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'servicenow_read_record' }, + fields: { + id: 'fields', + type: 'short-input', + value: 'number,short_description,priority', + }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.readFields.value).toBe('number,short_description,priority') + expect(blocks.b1.subBlocks.fields).toBeUndefined() + }) + + /** + * The shipped block shared `fields` between the Create/Update Record JSON + * body and the Read Records projection, and a subblock value survives an + * operation switch. So `operation: servicenow_read_record` holding a JSON + * body under `fields` is a reachable saved state, and promoting that body + * onto `readFields` would send it as `sysparm_fields`. + */ + it('leaves a Create Record JSON body under fields when the operation was switched to Read Records', () => { + const body = '{\n "short_description": "Issue description",\n "priority": "1"\n}' + const input: Record = { + b1: makeBlock({ + type: 'servicenow', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'servicenow_read_record' }, + fields: { id: 'fields', type: 'code', value: body }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.readFields).toBeUndefined() + expect(blocks.b1.subBlocks.fields.value).toBe(body) + }) + + /** + * A scalar body carries no `{` or `[`, so a prefix check would have read it + * as a field list. Parsing is what separates the two spaces. + */ + it.each([ + ['a boolean', 'true'], + ['a quoted string', '"short_description"'], + ['a number', '42'], + ['null', 'null'], + ])('leaves %s under fields rather than promoting it to a projection', (_label, body) => { + const input: Record = { + b1: makeBlock({ + type: 'servicenow', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'servicenow_read_record' }, + fields: { id: 'fields', type: 'code', value: body }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.readFields).toBeUndefined() + expect(blocks.b1.subBlocks.fields.value).toBe(body) + }) + + /** + * A saved body is not always well-formed JSON — it can be a half-typed + * draft or carry an unquoted `` reference. Migrating one + * moves it to `readFields` AND drops the original key, so the draft is + * gone. The field-list shape is what rejects these; parseability cannot. + */ + it.each([ + ['a half-typed body', '{\n "short_description": '], + ['an unquoted block reference', '{ "short_description": }'], + ['a trailing-comma body', '{ "priority": "1", }'], + ['a quoted field name', '"short_description"'], + ])('leaves %s under fields', (_label, body) => { + const input: Record = { + b1: makeBlock({ + type: 'servicenow', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'servicenow_read_record' }, + fields: { id: 'fields', type: 'code', value: body }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.readFields).toBeUndefined() + expect(blocks.b1.subBlocks.fields.value).toBe(body) + }) + + it('still migrates a dotted-walk projection', () => { + const input: Record = { + b1: makeBlock({ + type: 'servicenow', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'servicenow_read_record' }, + fields: { id: 'fields', type: 'short-input', value: 'number, cmdb_ci.name, sys_id' }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.readFields.value).toBe('number, cmdb_ci.name, sys_id') + }) + + it('leaves a JSON array value under fields as well', () => { + const input: Record = { + b1: makeBlock({ + type: 'servicenow', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'servicenow_read_record' }, + fields: { id: 'fields', type: 'code', value: '["short_description"]' }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.readFields).toBeUndefined() + expect(blocks.b1.subBlocks.fields.value).toBe('["short_description"]') + }) + + it('leaves the JSON body alone on create', () => { + const input: Record = { + b1: makeBlock({ + type: 'servicenow', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'servicenow_create_record' }, + fields: { id: 'fields', type: 'code', value: '{"short_description":"x"}' }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.readFields).toBeUndefined() + expect(blocks.b1.subBlocks.fields.value).toBe('{"short_description":"x"}') + }) + }) + + /** + * `okta_remove_user_from_app` reached the block in #6741 (`d45dad7e8b`), + * whose only release tag is v0.8.3 — the same release that split `sendEmail` + * into `sendDeactivationEmail`. In v0.8.2 the operation does not exist and + * `sendEmail` covers only activate/deactivate/reset/delete, so no saved state + * can hold a remove-from-app preference under `sendEmail`, and widening the + * scope would only let an activation-era value be promoted. + */ + describe('okta block', () => { + it('renames the deactivation half of the shared send-email switch', () => { + const input: Record = { + b1: makeBlock({ + type: 'okta', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'okta_deactivate_user' }, + sendEmail: { id: 'sendEmail', type: 'switch', value: 'true' }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.sendDeactivationEmail.value).toBe('true') + expect(blocks.b1.subBlocks.sendEmail).toBeUndefined() + }) + + it('leaves the activation half on sendEmail', () => { + const input: Record = { + b1: makeBlock({ + type: 'okta', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'okta_activate_user' }, + sendEmail: { id: 'sendEmail', type: 'switch', value: 'false' }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.sendEmail.value).toBe('false') + expect(blocks.b1.subBlocks.sendDeactivationEmail).toBeUndefined() + }) + }) + it('should handle blocks with empty subBlocks', () => { const input: Record = { b1: makeBlock({ type: 'knowledge', subBlocks: {} }), diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index fb2cb8e7661..d5fe7ccc760 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -19,6 +19,86 @@ const logger = createLogger('SubblockMigrations') */ const REMOVED_SUBBLOCK_ID_PREFIX = '_removed_' +/** + * One legacy-to-current subblock ID mapping for a block type. + * + * `whenOperation` scopes the rename to the operations the old ID belonged to. + * Leave it off for an unconditional rename — the shape every entry predating + * operation scoping uses. + * + * Scoping is required whenever the old ID is still a LIVE control for some + * other operation on the same block. Cloudflare `type` is the + * `list_dns_records` filter, `tags` the `purge_cache` list, `order`/`status` + * the `list_zones` filters; ServiceNow `fields` is the Create/Update Record + * JSON body — a different value space entirely; Okta `sendEmail` is the + * activation/reset switch. An unconditional rename would move a value out of + * the field that still owns it. + */ +export interface SubblockIdMigration { + /** The subblock ID a legacy saved state stores the value under. */ + from: string + /** + * The current subblock ID, or a `_removed_`-prefixed name meaning the field + * was deleted outright and the stored value should be dropped. + */ + to: string + /** + * Apply only when the block's stored `operation` value is one of these. + * Omit for an unconditional rename. + */ + whenOperation?: readonly string[] + /** + * Apply only when the stored value passes this check. Needed when the legacy + * ID served two incompatible value spaces that the stored `operation` alone + * cannot separate, because a value written under one operation survives a + * switch to another: subblock values are keyed by ID and are never cleared + * when the operation changes. + */ + whenValue?: (value: unknown) => boolean +} + +/** + * Whether a stored value could plausibly be a comma-separated field + * projection rather than a JSON body. + * + * The shipped ServiceNow block declared `fields` three times — the Create and + * Update Record JSON bodies and the Read Records projection — so one stored + * value served both spaces. A block configured for Create Record and later + * switched to Read Records without clearing the field carries the JSON body + * under `fields` while `operation` already reads `servicenow_read_record`, so + * the operation scope alone cannot tell a projection from a body. Promoting a + * body onto `readFields` would send it as `sysparm_fields`, which is exactly + * the cross-space leak the rename closed. + * + * The test is a positive allowlist, not a check for body-shaped input, because + * a body is only reliably recognisable when it is well formed. A saved body can + * be a half-typed draft (`{"short_description": `) or carry an unquoted + * `` reference, so neither "opens with a brace" nor "fails to + * parse as JSON" identifies one: the first misses a bare scalar body like + * `true`, the second misses both of those. Migrating one would move it to + * `readFields` AND drop the original key, losing the draft. + * + * A projection is a comma-separated list of ServiceNow field names, which are + * word characters plus the dot of a dotted walk. Anything carrying a brace, + * quote, colon, angle bracket or interior space fails that shape. `JSON.parse` + * then removes the bare scalars (`true`, `42`, `null`) that satisfy it by + * accident. Ambiguity resolves to "not a projection", leaving the value on + * `fields` where the Create/Update control still owns it. + */ +const SERVICENOW_FIELD_LIST = /^[A-Za-z0-9_.]+(?:\s*,\s*[A-Za-z0-9_.]+)*$/ + +function isFieldProjection(value: unknown): boolean { + if (typeof value !== 'string') return false + const trimmed = value.trim() + if (!SERVICENOW_FIELD_LIST.test(trimmed)) return false + try { + JSON.parse(trimmed) + return false + } catch { + return true + } +} + /** * Maps old subblock IDs to their current equivalents per block type. * @@ -26,70 +106,62 @@ const REMOVED_SUBBLOCK_ID_PREFIX = '_removed_' * still carry the value under the previous key. Without this mapping the * serializer silently drops the value, breaking execution. * - * Format: { blockType: { oldSubblockId: newSubblockId } } - * - * A target prefixed with `_removed_` means the field was deleted outright; the + * A `to` prefixed with `_removed_` means the field was deleted outright; the * stored value is dropped. Use it for fields with no replacement — never map a * secret onto a live subblock. */ -export const SUBBLOCK_ID_MIGRATIONS: Record> = { - instagram: { - metrics: 'insightMetrics', - }, - knowledge: { - knowledgeBaseId: 'knowledgeBaseSelector', - }, - algolia: { - listPage: 'page', - listHitsPerPage: 'hitsPerPage', - }, - kalshi: { - settlementStatus: '_removed_settlementStatus', - }, - dynamodb: { - key: 'getKey', - filterExpression: 'queryFilterExpression', - expressionAttributeNames: 'queryExpressionAttributeNames', - expressionAttributeValues: 'queryExpressionAttributeValues', - limit: 'queryLimit', - conditionExpression: 'updateConditionExpression', - }, - ashby: { - emailType: '_removed_emailType', - phoneType: '_removed_phoneType', - expandApplicationFormDefinition: '_removed_expandApplicationFormDefinition', - expandSurveyFormDefinitions: '_removed_expandSurveyFormDefinitions', - filterCandidateId: '_removed_filterCandidateId', - }, - clickup: { - workspaceId: 'workspaceSelector', - spaceId: 'spaceSelector', - listSpaceId: 'listSpaceSelector', - folderId: 'folderSelector', - listId: 'listSelector', - }, - apollo: { - contact_ids_bulk: 'contacts', - account_ids_bulk: 'accounts', - close_date: 'closed_date', - stage_id: 'opportunity_stage_id', - note: 'task_notes', - description: '_removed_description', - stage_ids: '_removed_stage_ids', - owner_ids: '_removed_owner_ids', - }, - exa: { - /** - * Exa deprecated both fields. `useAutoprompt` is gone from the API, and - * `livecrawl` is superseded by `maxAgeHours` — but their values are not - * interchangeable (`livecrawl` is a mode string, `maxAgeHours` a number), - * so mapping one onto the other would send `NaN`. Dropping `livecrawl` is - * also the fix for the block having defaulted it to `never`, which pinned - * every saved search to cached results. - */ - useAutoprompt: '_removed_useAutoprompt', - livecrawl: '_removed_livecrawl', - }, +export const SUBBLOCK_ID_MIGRATIONS: Record = { + instagram: [{ from: 'metrics', to: 'insightMetrics' }], + knowledge: [{ from: 'knowledgeBaseId', to: 'knowledgeBaseSelector' }], + algolia: [ + { from: 'listPage', to: 'page' }, + { from: 'listHitsPerPage', to: 'hitsPerPage' }, + ], + kalshi: [{ from: 'settlementStatus', to: '_removed_settlementStatus' }], + dynamodb: [ + { from: 'key', to: 'getKey' }, + { from: 'filterExpression', to: 'queryFilterExpression' }, + { from: 'expressionAttributeNames', to: 'queryExpressionAttributeNames' }, + { from: 'expressionAttributeValues', to: 'queryExpressionAttributeValues' }, + { from: 'limit', to: 'queryLimit' }, + { from: 'conditionExpression', to: 'updateConditionExpression' }, + ], + ashby: [ + { from: 'emailType', to: '_removed_emailType' }, + { from: 'phoneType', to: '_removed_phoneType' }, + { from: 'expandApplicationFormDefinition', to: '_removed_expandApplicationFormDefinition' }, + { from: 'expandSurveyFormDefinitions', to: '_removed_expandSurveyFormDefinitions' }, + { from: 'filterCandidateId', to: '_removed_filterCandidateId' }, + ], + clickup: [ + { from: 'workspaceId', to: 'workspaceSelector' }, + { from: 'spaceId', to: 'spaceSelector' }, + { from: 'listSpaceId', to: 'listSpaceSelector' }, + { from: 'folderId', to: 'folderSelector' }, + { from: 'listId', to: 'listSelector' }, + ], + apollo: [ + { from: 'contact_ids_bulk', to: 'contacts' }, + { from: 'account_ids_bulk', to: 'accounts' }, + { from: 'close_date', to: 'closed_date' }, + { from: 'stage_id', to: 'opportunity_stage_id' }, + { from: 'note', to: 'task_notes' }, + { from: 'description', to: '_removed_description' }, + { from: 'stage_ids', to: '_removed_stage_ids' }, + { from: 'owner_ids', to: '_removed_owner_ids' }, + ], + /** + * Exa deprecated both fields. `useAutoprompt` is gone from the API, and + * `livecrawl` is superseded by `maxAgeHours` — but their values are not + * interchangeable (`livecrawl` is a mode string, `maxAgeHours` a number), + * so mapping one onto the other would send `NaN`. Dropping `livecrawl` is + * also the fix for the block having defaulted it to `never`, which pinned + * every saved search to cached results. + */ + exa: [ + { from: 'useAutoprompt', to: '_removed_useAutoprompt' }, + { from: 'livecrawl', to: '_removed_livecrawl' }, + ], /** * The Snowflake block moved from per-block `host` + `apiKey` fields to a * stored credential, and gave every object field a basic picker paired with @@ -102,71 +174,139 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { * while the picker lists bare names, so it could never resolve. The host and * token have no in-block equivalent and are dropped. */ - snowflake: { - database: 'databaseName', - schema: 'schemaName', - table: 'tableName', - fileFormat: 'fileFormatName', - warehouseName: 'warehouseNameManual', - procedureName: 'procedureNameManual', - warehouse: 'warehouseManual', - role: 'roleManual', - host: '_removed_host', - apiKey: '_removed_apiKey', - }, + snowflake: [ + { from: 'database', to: 'databaseName' }, + { from: 'schema', to: 'schemaName' }, + { from: 'table', to: 'tableName' }, + { from: 'fileFormat', to: 'fileFormatName' }, + { from: 'warehouseName', to: 'warehouseNameManual' }, + { from: 'procedureName', to: 'procedureNameManual' }, + { from: 'warehouse', to: 'warehouseManual' }, + { from: 'role', to: 'roleManual' }, + { from: 'host', to: '_removed_host' }, + { from: 'apiKey', to: '_removed_apiKey' }, + ], /** - * The Cloudflare block briefly gave its DNS/zone read filters and its cache-purge - * tag list operation-suffixed IDs, and added a single shared `cursor`. This PR - * restores the shipped IDs (`name`, `type`, `content`, `proxied`, `tags`) so saved - * workflows keep filtering, and splits the cursor per endpoint. + * Two unrelated Cloudflare changes land here. * - * The suffixed IDs are dropped rather than renamed onto their shipped - * counterparts. They existed only between #6740 and this change and never - * appeared in a release, so no deployed workflow carries them — and a rename - * could not restore a value even for a workflow edited in that window. Block - * state materializes an entry for every subblock the config declares, not just - * the active operation's, so `name`/`type`/`content`/`proxied`/`tags` are always - * already present; {@link migrateBlockSubblockIds} would hit its collision guard - * and discard the source value anyway. Mapping them as renames would therefore - * claim a recovery that never happens, while leaving the stale value parked in - * state and riding along in exports. + * The `_removed_` entries: #6740 briefly gave the DNS/zone read filters and + * the cache-purge tag list operation-suffixed IDs, and added a single shared + * `cursor`. A later change restored the shipped filter IDs and split the + * cursor per endpoint. Those suffixed IDs existed only between #6740 and that + * change and never appeared in a release, so no saved workflow carries a + * value worth recovering; they are dropped rather than renamed so nothing + * stays parked in state and rides along in exports. * - * `cursor` split into `r2Cursor` and `rulesetCursor`, so there is no single - * replacement to name. + * The scoped entries: #6740 also renamed the DNS write controls off the bare + * IDs the SHIPPED block stored them under. Before it, one `type` control + * served `list_dns_records`, `create_dns_record`, AND `update_dns_record` — + * a single stored value for all three, which is exactly the ambiguity the + * rename removed. Every affected ID stayed live for the read filter, so each + * rename is scoped to the operation that owned the written value. Without + * these, a saved proxied A record is recreated UNPROXIED — publishing the + * origin IP and bypassing the WAF/CDN — and `update_dns_record` silently + * becomes a no-op. */ - cloudflare: { - zoneNameFilter: '_removed_zoneNameFilter', - dnsNameFilter: '_removed_dnsNameFilter', - dnsTypeFilter: '_removed_dnsTypeFilter', - dnsContentFilter: '_removed_dnsContentFilter', - dnsProxiedFilter: '_removed_dnsProxiedFilter', - purgeTags: '_removed_purgeTags', - cursor: '_removed_cursor', - }, - rippling: { - action: '_removed_action', - candidateDepartment: '_removed_candidateDepartment', - candidatePhone: '_removed_candidatePhone', - candidateStartDate: '_removed_candidateStartDate', - email: '_removed_email', - employeeId: '_removed_employeeId', - endDate: '_removed_endDate', - firstName: '_removed_firstName', - groupId: '_removed_groupId', - groupName: '_removed_groupName', - groupVersion: '_removed_groupVersion', - jobTitle: '_removed_jobTitle', - lastName: '_removed_lastName', - leaveRequestId: '_removed_leaveRequestId', - managedBy: '_removed_managedBy', - nextCursor: '_removed_nextCursor', - offset: '_removed_offset', - roleId: '_removed_roleId', - spokeId: '_removed_spokeId', - startDate: '_removed_startDate', - status: '_removed_status', - users: '_removed_users', - }, + cloudflare: [ + { from: 'zoneNameFilter', to: '_removed_zoneNameFilter' }, + { from: 'dnsNameFilter', to: '_removed_dnsNameFilter' }, + { from: 'dnsTypeFilter', to: '_removed_dnsTypeFilter' }, + { from: 'dnsContentFilter', to: '_removed_dnsContentFilter' }, + { from: 'dnsProxiedFilter', to: '_removed_dnsProxiedFilter' }, + { from: 'purgeTags', to: '_removed_purgeTags' }, + { from: 'cursor', to: '_removed_cursor' }, + { from: 'type', to: 'recordType', whenOperation: ['create_dns_record'] }, + { from: 'proxied', to: 'recordProxied', whenOperation: ['create_dns_record'] }, + { from: 'tags', to: 'recordTags', whenOperation: ['create_dns_record'] }, + { from: 'type', to: 'updateRecordType', whenOperation: ['update_dns_record'] }, + { from: 'name', to: 'updateRecordName', whenOperation: ['update_dns_record'] }, + { from: 'content', to: 'updateRecordContent', whenOperation: ['update_dns_record'] }, + { from: 'proxied', to: 'updateRecordProxied', whenOperation: ['update_dns_record'] }, + { from: 'tags', to: 'updateRecordTags', whenOperation: ['update_dns_record'] }, + { from: 'order', to: 'dnsOrder', whenOperation: ['list_dns_records'] }, + { from: 'status', to: 'certificateStatus', whenOperation: ['list_certificates'] }, + ], + /** + * Read Records moved its field projection off `fields`, which the shipped + * block shared with the Create/Update Record JSON body. The two value spaces + * are incompatible — a body sent as `sysparm_fields` goes out as + * `[object Object]` — so the rename is scoped to Read Records and the body + * keeps `fields` untouched on create and update. + * + * The operation scope is not enough on its own: a block configured for + * Create Record and then switched to Read Records still holds the JSON body + * under `fields`, so the value is guarded as well. + */ + servicenow: [ + { + from: 'fields', + to: 'readFields', + whenOperation: ['servicenow_read_record'], + whenValue: isFieldProjection, + }, + ], + /** + * One `sendEmail` switch used to serve activation, password reset, + * deactivation, and deletion. Okta's API default is not uniform across those + * — activation and reset default to sending, deactivation and removal to not + * sending — so the switch split in two. `sendEmail` remains live for the + * activation/reset half, so only the deactivation half is renamed. + */ + okta: [ + { + from: 'sendEmail', + to: 'sendDeactivationEmail', + whenOperation: ['okta_deactivate_user', 'okta_delete_user'], + }, + ], + rippling: [ + { from: 'action', to: '_removed_action' }, + { from: 'candidateDepartment', to: '_removed_candidateDepartment' }, + { from: 'candidatePhone', to: '_removed_candidatePhone' }, + { from: 'candidateStartDate', to: '_removed_candidateStartDate' }, + { from: 'email', to: '_removed_email' }, + { from: 'employeeId', to: '_removed_employeeId' }, + { from: 'endDate', to: '_removed_endDate' }, + { from: 'firstName', to: '_removed_firstName' }, + { from: 'groupId', to: '_removed_groupId' }, + { from: 'groupName', to: '_removed_groupName' }, + { from: 'groupVersion', to: '_removed_groupVersion' }, + { from: 'jobTitle', to: '_removed_jobTitle' }, + { from: 'lastName', to: '_removed_lastName' }, + { from: 'leaveRequestId', to: '_removed_leaveRequestId' }, + { from: 'managedBy', to: '_removed_managedBy' }, + { from: 'nextCursor', to: '_removed_nextCursor' }, + { from: 'offset', to: '_removed_offset' }, + { from: 'roleId', to: '_removed_roleId' }, + { from: 'spokeId', to: '_removed_spokeId' }, + { from: 'startDate', to: '_removed_startDate' }, + { from: 'status', to: '_removed_status' }, + { from: 'users', to: '_removed_users' }, + ], +} + +/** Reads the value out of a stored subblock entry, tolerating a bare value. */ +function storedSubblockValue(entry: unknown): unknown { + if (isPlainRecord(entry)) return Object.hasOwn(entry, 'value') ? entry.value : null + return entry +} + +/** A stored value carries nothing worth migrating when it is absent or blank. */ +function isBlankValue(value: unknown): boolean { + return value === undefined || value === null || value === '' +} + +/** + * The operation a block is currently configured for, or `null` when it has + * none. An operation-scoped migration cannot fire without it: the whole point + * of the scope is that the same stored ID means different things per operation, + * so an unknown operation must leave the value alone. + */ +function selectedOperation( + subBlocks: Record +): string | null { + const value = storedSubblockValue(subBlocks.operation) + return typeof value === 'string' && value !== '' ? value : null } /** @@ -176,42 +316,91 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { function migrateBlockSubblockIds( blockType: string, subBlocks: Record, - renames: Record + migrations: readonly SubblockIdMigration[] ): { subBlocks: Record; migrated: boolean } { - let migrated = false - - for (const oldId of Object.keys(renames)) { - if (oldId in subBlocks) { - migrated = true + let touched = false + for (const { from } of migrations) { + if (from in subBlocks) { + touched = true break } } - if (!migrated) return { subBlocks, migrated: false } + if (!touched) return { subBlocks, migrated: false } const result = { ...subBlocks } const blockConfig = getBlock(blockType) + const operation = selectedOperation(subBlocks) + let migrated = false - for (const [oldId, newId] of Object.entries(renames)) { - if (!(oldId in result)) continue + for (const { from, to, whenOperation, whenValue } of migrations) { + if (!(from in result)) continue // A `_removed_` target means the field no longer exists in the block. Drop // the value rather than parking it under a dead key: nothing ever reads // these keys, and secret scrubbing walks the block config, so a parked // `password: true` value would never be cleared and would ride along in // workflow exports and templates. - if (newId.startsWith(REMOVED_SUBBLOCK_ID_PREFIX)) { - delete result[oldId] + if (to.startsWith(REMOVED_SUBBLOCK_ID_PREFIX)) { + delete result[from] + migrated = true continue } - if (newId in result) { - delete result[oldId] + if (whenOperation) { + // Scoped renames only fire for the operation that owned the written + // value. The source ID is still a live control for some other operation, + // so applying one outside its scope would steal that operation's value — + // and so would deleting the source outside its scope. + if (operation === null || !whenOperation.includes(operation)) continue + + /** + * The collision guard for a scoped rename, and the whole discriminator + * for "is this state older than the rename?". + * + * Presence of the target ID means the state was written by a block config + * that already declared it. `prepareBlockState` materializes an entry for + * EVERY declared subblock at block-creation time and the add-block write + * persists that map wholesale, so a block created after a rename always + * carries the new ID — seeded, or explicitly `null` when the control has + * no seed. A block created before it cannot carry the new ID at all: the + * loader reads `workflow_blocks.sub_blocks` verbatim and no load-time + * step hydrates missing declared subblocks. + * + * So "target absent" is exactly "this state predates the rename", and it + * is the only condition under which the legacy value may move. + * Overwriting a target that is merely blank or still at its seeded + * default would reopen the cross-operation leak the renames closed — a + * `list_dns_records` name filter promoted onto `updateRecordName` renames + * a live DNS record, and a Create Record JSON body promoted onto + * `readFields` goes out as `sysparm_fields=[object Object]`. + * + * A legacy value is therefore never clobbered by a live user pick, and a + * live user pick is never clobbered by a legacy value. Leaving the source + * in place here is deliberate: the target already owns the value, and the + * source may still be this block's control for another operation. + */ + if (to in result) continue + + // Nothing to recover, and writing the blank through would only create a + // second key holding the same emptiness. + if (isBlankValue(storedSubblockValue(result[from]))) continue + } else if (to in result) { + // Unconditional rename onto an occupied target: the target wins and the + // legacy value is discarded, which is how every pre-scoping entry here + // has always behaved. + delete result[from] + migrated = true continue } - const oldEntry: unknown = result[oldId] - const configuredType = blockConfig?.subBlocks?.find((config) => config.id === newId)?.type + // A value the target's space cannot represent belongs to whichever control + // still owns the source ID. Leave it there rather than moving it into a + // field that would send it as something it is not. + if (whenValue && !whenValue(storedSubblockValue(result[from]))) continue + + const oldEntry: unknown = result[from] + const configuredType = blockConfig?.subBlocks?.find((config) => config.id === to)?.type if (isPlainRecord(oldEntry)) { const type = configuredType || @@ -222,23 +411,24 @@ function migrateBlockSubblockIds( : DEFAULT_SUBBLOCK_TYPE) const value = Object.hasOwn(oldEntry, 'value') ? oldEntry.value : null - result[newId] = { + result[to] = { ...oldEntry, - id: newId, + id: to, type: type as BlockState['subBlocks'][string]['type'], value: value as BlockState['subBlocks'][string]['value'], } } else { - result[newId] = { - id: newId, + result[to] = { + id: to, type: configuredType || DEFAULT_SUBBLOCK_TYPE, value: oldEntry as BlockState['subBlocks'][string]['value'], } } - delete result[oldId] + delete result[from] + migrated = true } - return { subBlocks: result, migrated: true } + return migrated ? { subBlocks: result, migrated: true } : { subBlocks, migrated: false } } /** @@ -281,9 +471,9 @@ export function migrateSubblockIds(blocks: Record): { continue } - const renames = SUBBLOCK_ID_MIGRATIONS[block.type] - const renamed = renames - ? migrateBlockSubblockIds(block.type, block.subBlocks, renames) + const migrations = SUBBLOCK_ID_MIGRATIONS[block.type] + const renamed = migrations + ? migrateBlockSubblockIds(block.type, block.subBlocks, migrations) : { subBlocks: block.subBlocks, migrated: false } const purged = dropParkedSubblocks(renamed.subBlocks) const changedSubBlocks = renamed.migrated || purged.dropped diff --git a/apps/sim/scripts/check-block-registry.ts b/apps/sim/scripts/check-block-registry.ts index f28ddeadda3..d5133c38a9b 100644 --- a/apps/sim/scripts/check-block-registry.ts +++ b/apps/sim/scripts/check-block-registry.ts @@ -171,11 +171,11 @@ function checkSubblockIdStability(): CheckResult { const currIds = current[blockType] if (!currIds) continue - const migrations = SUBBLOCK_ID_MIGRATIONS[blockType] ?? {} + const migrations = SUBBLOCK_ID_MIGRATIONS[blockType] ?? [] for (const oldId of prevIds) { if (currIds.has(oldId)) continue - if (oldId in migrations) continue + if (migrations.some((migration) => migration.from === oldId)) continue errors.push( `Block "${blockType}": subblock ID "${oldId}" was removed.\n` + diff --git a/apps/sim/tailwind.config.ts b/apps/sim/tailwind.config.ts index f89fa302dff..a5b0aeb77fd 100644 --- a/apps/sim/tailwind.config.ts +++ b/apps/sim/tailwind.config.ts @@ -31,8 +31,6 @@ export default { */ './blocks/**/*.{js,ts,jsx,tsx}', './ee/**/*.{js,ts,jsx,tsx}', - './providers/**/*.{js,ts,jsx,tsx}', - './tools/**/*.{js,ts,jsx,tsx}', './content/**/*.{js,ts,jsx,tsx}', '../../packages/emcn/src/**/*.{js,ts,jsx,tsx}', '../../packages/workflow-renderer/src/**/*.{js,ts,jsx,tsx}', diff --git a/findings.txt b/findings.txt deleted file mode 100644 index 92450d24ace..00000000000 --- a/findings.txt +++ /dev/null @@ -1,19 +0,0 @@ -# Behavior change (resolved) - -- [HIGH][RESOLVED] `apps/sim/app/api/auth/shopify/authorize/route.ts:44` introduced an authenticated reflected-XSS path. Inline script values now escape `<` as a Unicode escape, with a regression test using a closing-script payload. - -- [HIGH][RESOLVED] `apps/sim/app/api/credentials/[id]/members/route.ts:24` changed roster authorization and concealment. Listing is workspace-read authorized again, inaccessible credentials are concealed as `404 Not found`, and missing POST/DELETE targets retain the uniform `403 Admin access required` response. - -- [HIGH][RESOLVED] OAuth disconnect deferred audit and analytics until every destructive step finished. A typed partial-failure now carries committed deletions through the application boundary, which records their audit and PostHog effects before rethrowing the original failure. - -- [MEDIUM][RESOLVED] Shopify return destinations were stored in one browser-wide cookie. Each return URL now travels in its own signed, user/shop-bound state token, and overlapping callbacks are tested independently. - -- [MEDIUM][RESOLVED] Reconnects mapped every forbidden operation to credential denial. Only `CREDENTIAL_ADMIN_ACCESS_REQUIRED` now maps to `credential_access_denied`; workspace-role failures map to `workspace_access_denied`. - -- [MEDIUM][RESOLVED] Draft-backed OAuth launch ran outside the browser redirect error boundary. Launch and target resolution now run inside it, so unknown failures redirect to `/workspace?error=oauth_link_failed`. - -- [MEDIUM][RESOLVED] Credential lookup was folded into filtered listing. The application use case now has a dedicated workspace-authorized, ID-first/account-ID-second lookup branch that skips sync and filters and returns exactly `{ credential }`. - -- [MEDIUM][RESOLVED] Environment deletion lost its per-type audit and analytics projection. Personal/workspace descriptions, `envKey` metadata, and the PostHog provider dimension are restored within the shared use case. - -- [LOW][RESOLVED] Credentials and connected-account queries lost legacy normalization. Their shared contracts now restore trimming/blank handling where previously supported and first-value-wins behavior for duplicate query keys. diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1aff218d5e7..1e26e115df9 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1131, - zodRoutes: 1131, + totalRoutes: 1119, + zodRoutes: 1119, nonZodRoutes: 0, } as const From 1d43639df85ffa87b80d3d0977a596922105de33 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 17 Aug 2026 16:11:53 -0700 Subject: [PATCH 06/26] fix(files): serve mothership chat attachments stored under a workspace key (#6789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mothership chat attachment is minted with the same storage key shape as a workspace file — `resolveUploadStorage` calls `generateWorkspaceFileKey` for `mothership_attachment` — but its row is written with `context = 'mothership'`. The serve route branched on the key prefix alone, so every attachment entered the workspace-file use case, which matches on `context = 'workspace'` and resolved it to nothing: `{"error":"FileNotFoundError","message":"File not found"}` for every chat thumbnail and click-through. The `context=mothership` query param on the serve URL is decorative; the route never read it. Serve now resolves the storage context from the key's stored binding, which is server-authored at upload and the only thing that separates the two, and passes it into the cloud and local handlers instead of re-inferring. Genuine workspace files still go through the authorized use case. `verifyWorkspaceFileAccess` takes the context too, so an attachment authorizes from its database row rather than falling through to storage-object metadata. A soft-deleted attachment is now denied, matching workspace files. The route tests are what let this ship: they mocked `inferContextFromKey` to return 'mothership' for a `workspace/…` key, which it never does. With the mock made honest, nine of them fail against the old route. --- apps/sim/app/api/files/authorization.ts | 29 ++++++++--- .../api/files/serve/[...path]/route.test.ts | 49 ++++++++++++++++++- .../app/api/files/serve/[...path]/route.ts | 27 +++++----- apps/sim/lib/uploads/server/metadata.test.ts | 36 ++++++++++++++ apps/sim/lib/uploads/server/metadata.ts | 23 +++++++++ 5 files changed, 142 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 819081ff2e0..64d3ee4650a 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -30,6 +30,13 @@ interface AuthorizationResult { type WorkspacePermission = 'read' | 'write' | 'admin' +/** + * The two contexts stored under a `workspace/…` key. They share a bucket and a + * workspace-membership permission model; only the owning module differs — a + * mothership attachment belongs to a chat, a workspace file to the Files module. + */ +type WorkspaceScopedContext = 'workspace' | 'mothership' + /** * Whether a resolved workspace permission satisfies a file operation. Read and * download paths accept any membership; destructive operations (`requireWrite`) @@ -49,12 +56,12 @@ function workspacePermissionSatisfies( */ async function lookupWorkspaceFileByKey( key: string, - options?: { includeDeleted?: boolean } + options?: { includeDeleted?: boolean; context?: WorkspaceScopedContext } ): Promise<{ workspaceId: string; uploadedBy: string } | null> { try { - const { includeDeleted = false } = options ?? {} + const { includeDeleted = false, context = 'workspace' } = options ?? {} // Priority 1: Check new workspaceFiles table - const fileRecord = await getFileMetadataByKey(key, 'workspace', { includeDeleted }) + const fileRecord = await getFileMetadataByKey(key, context, { includeDeleted }) if (fileRecord) { return { @@ -158,7 +165,14 @@ export async function verifyFileAccess( // 1. Workspace / mothership files: Check database first (most reliable for both local and cloud) if (inferredContext === 'workspace' || inferredContext === 'mothership') { - return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite) + return await verifyWorkspaceFileAccess( + cloudKey, + userId, + customConfig, + isLocal, + requireWrite, + inferredContext + ) } // 2. Execution files: workspace_id/workflow_id/execution_id/filename @@ -200,10 +214,11 @@ async function verifyWorkspaceFileAccess( userId: string, customConfig?: StorageConfig, isLocal?: boolean, - requireWrite = false + requireWrite = false, + context: WorkspaceScopedContext = 'workspace' ): Promise { try { - const anyWorkspaceFileRecord = await getFileMetadataByKey(cloudKey, 'workspace', { + const anyWorkspaceFileRecord = await getFileMetadataByKey(cloudKey, context, { includeDeleted: true, }) if (anyWorkspaceFileRecord?.deletedAt) { @@ -215,7 +230,7 @@ async function verifyWorkspaceFileAccess( } // Priority 1: Check database (most reliable, works for both local and cloud) - const workspaceFileRecord = await lookupWorkspaceFileByKey(cloudKey) + const workspaceFileRecord = await lookupWorkspaceFileByKey(cloudKey, { context }) if (workspaceFileRecord) { const permission = await getUserEntityPermissions( userId, diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 799fb19b97c..27a8d39ce37 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -20,6 +20,7 @@ const { mockIsUsingCloudStorage, mockDownloadCopilotFile, mockInferContextFromKey, + mockResolveStoredFileContext, mockParseWorkspaceFileKey, mockAuthenticateWorkspaceFile, mockReadWorkspaceFileContentByKey, @@ -44,6 +45,7 @@ const { mockIsUsingCloudStorage: vi.fn(), mockDownloadCopilotFile: vi.fn(), mockInferContextFromKey: vi.fn(), + mockResolveStoredFileContext: vi.fn(), mockParseWorkspaceFileKey: vi.fn(), mockAuthenticateWorkspaceFile: vi.fn(), mockReadWorkspaceFileContentByKey: vi.fn(), @@ -79,6 +81,10 @@ vi.mock('@/lib/uploads/utils/file-utils', () => ({ inferContextFromKey: mockInferContextFromKey, })) +vi.mock('@/lib/uploads/server/metadata', () => ({ + resolveStoredFileContext: mockResolveStoredFileContext, +})) + vi.mock('@/lib/uploads/setup.server', () => ({})) vi.mock('@/lib/execution/sandbox/run-task', () => ({ @@ -129,7 +135,11 @@ describe('File Serve API Route', () => { mockReadFile.mockResolvedValue(Buffer.from('test content')) mockIsUsingCloudStorage.mockReturnValue(false) storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) - mockInferContextFromKey.mockReturnValue('mothership') + // A `workspace/…` key is what both a workspace file and a mothership chat + // attachment carry; only the stored binding tells them apart, so the default + // here is the attachment and the workspace cases opt in explicitly. + mockInferContextFromKey.mockReturnValue('workspace') + mockResolveStoredFileContext.mockResolvedValue('mothership') mockParseWorkspaceFileKey.mockReturnValue(undefined) mockAuthenticateWorkspaceFile.mockResolvedValue({ kind: 'session', @@ -240,7 +250,7 @@ describe('File Serve API Route', () => { workflowId: 'workflow-1', }, } - mockInferContextFromKey.mockReturnValue('workspace') + mockResolveStoredFileContext.mockResolvedValue('workspace') mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id') mockAuthenticateWorkspaceFile.mockResolvedValue(principal) mockResolveServableDocBytes.mockResolvedValue({ @@ -276,6 +286,41 @@ describe('File Serve API Route', () => { expect(mockVerifyFileAccess).not.toHaveBeenCalled() }) + it('serves a mothership chat attachment stored under a workspace key', async () => { + /** + * The attachment shares the `workspace/…` prefix but is recorded as + * `context = 'mothership'`, so the workspace-file use case — which matches on + * `context = 'workspace'` — would answer 404 for a file that is right there. + */ + mockIsUsingCloudStorage.mockReturnValue(true) + storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('attachment bytes')) + mockGetContentType.mockReturnValue('image/png') + + const req = new NextRequest( + 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/1234567890-photo.png?preview=1' + ) + const response = await GET(req, { + params: Promise.resolve({ + path: ['workspace', 'test-workspace-id', '1234567890-photo.png'], + }), + }) + + expect(response.status).toBe(200) + expect(mockReadWorkspaceFileContentByKey).not.toHaveBeenCalled() + expect(mockAuthenticateWorkspaceFile).not.toHaveBeenCalled() + expect(mockVerifyFileAccess).toHaveBeenCalledWith( + 'workspace/test-workspace-id/1234567890-photo.png', + 'test-user-id', + undefined, + 'mothership', + false + ) + expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledWith({ + key: 'workspace/test-workspace-id/1234567890-photo.png', + context: 'mothership', + }) + }) + it('should return 404 when file not found', async () => { mockVerifyFileAccess.mockResolvedValue(false) mockFindLocalFile.mockReturnValue(null) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 0899cdd0dfa..a36b814fb16 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -18,6 +18,7 @@ import type { StorageContext } from '@/lib/uploads/config' import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' +import { resolveStoredFileContext } from '@/lib/uploads/server/metadata' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api' import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key' @@ -165,7 +166,10 @@ export const GET = withRouteHandler( return await handleLocalFilePublic(fullPath) } - const storageContext = inferContextFromKey(cloudKey) + // Resolved from the key's stored binding, not its prefix alone: a mothership chat + // attachment carries a `workspace/…` key but is not a workspace file, and the + // workspace-file use case below would resolve it to a 404. + const storageContext = await resolveStoredFileContext(cloudKey) const workspacePrincipal = storageContext === 'workspace' ? await internalWorkspaceFileServeAuth.authenticate(request, { path }) @@ -201,10 +205,10 @@ export const GET = withRouteHandler( if (!userId) throw new Error('Authenticated file serve request is missing a user ID') if (isUsingCloudStorage()) { - return await handleCloudProxy(cloudKey, userId, options, request.signal) + return await handleCloudProxy(cloudKey, userId, options, request.signal, storageContext) } - return await handleLocalFile(cloudKey, userId, options, request.signal) + return await handleLocalFile(cloudKey, userId, options, request.signal, storageContext) } catch (error) { if (error instanceof InternalUnauthenticatedError) { logger.warn('Unauthorized file access attempt', { error: error.message }) @@ -285,19 +289,16 @@ async function handleLocalFile( filename: string, userId: string, options: ServeOptions, - signal: AbortSignal | undefined + signal: AbortSignal | undefined, + context: StorageContext ): Promise { const ownerKey = `user:${userId}` try { - const contextParam: StorageContext | undefined = inferContextFromKey(filename) as - | StorageContext - | undefined - const hasAccess = await verifyFileAccess( filename, userId, undefined, // customConfig - contextParam, // context + context, true // isLocal ) @@ -332,7 +333,7 @@ async function handleLocalFile( buffer: fileBuffer, contentType, filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, contextParam), + cacheControl: resolveServeCacheControl(options.versioned, context), }) } catch (error) { logServeFailure('Error reading local file:', error) @@ -344,12 +345,12 @@ async function handleCloudProxy( cloudKey: string, userId: string, options: ServeOptions, - signal: AbortSignal | undefined + signal: AbortSignal | undefined, + context: StorageContext ): Promise { const ownerKey = `user:${userId}` try { - const context = inferContextFromKey(cloudKey) - logger.info(`Inferred context: ${context} from key pattern: ${cloudKey}`) + logger.info(`Resolved context: ${context} for key: ${cloudKey}`) const hasAccess = await verifyFileAccess( cloudKey, diff --git a/apps/sim/lib/uploads/server/metadata.test.ts b/apps/sim/lib/uploads/server/metadata.test.ts index 19512aa03f9..585adba9715 100644 --- a/apps/sim/lib/uploads/server/metadata.test.ts +++ b/apps/sim/lib/uploads/server/metadata.test.ts @@ -18,6 +18,7 @@ import { insertFileMetadataMany, insertImmutableFileMetadata, recordKnowledgeBaseFileOwnership, + resolveStoredFileContext, } from '@/lib/uploads/server/metadata' describe('recordKnowledgeBaseFileOwnership', () => { @@ -318,3 +319,38 @@ describe('insertFileMetadataMany active-key idempotence', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) }) + +describe('resolveStoredFileContext', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + const workspaceKey = 'workspace/workspace-1/1234567890-abcdef-photo.png' + + it('reports a mothership attachment stored under a workspace key', async () => { + queueTableRows(workspaceFiles, [ + { id: 'file-1', key: workspaceKey, context: 'mothership', deletedAt: null }, + ]) + + await expect(resolveStoredFileContext(workspaceKey)).resolves.toBe('mothership') + }) + + it('keeps a workspace file on the workspace context', async () => { + queueTableRows(workspaceFiles, [ + { id: 'file-1', key: workspaceKey, context: 'workspace', deletedAt: null }, + ]) + + await expect(resolveStoredFileContext(workspaceKey)).resolves.toBe('workspace') + }) + + it('falls back to the inferred context for an unbound key', async () => { + await expect(resolveStoredFileContext(workspaceKey)).resolves.toBe('workspace') + }) + + it('trusts the prefix without a lookup when it cannot be a workspace key', async () => { + await expect(resolveStoredFileContext('copilot/file.png')).resolves.toBe('copilot') + + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index ec6e64c2204..4485a9e0661 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import type { DbOrTx, DbTransaction } from '@/lib/db/types' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { type StorageContext, toLegacyWorkspaceFileSize } from '../shared/types' const logger = createLogger('FileMetadata') @@ -336,6 +337,28 @@ export async function getFileMetadataByKey( return record ?? null } +/** + * Resolve the storage context a stored object must be read and authorized under. + * + * A `workspace/…` key prefix is not by itself proof of a workspace file. A + * mothership chat attachment is minted with the same prefix — same bucket, same + * workspace scope — but is recorded as `context = 'mothership'` and never enters + * the Files module, so every workspace-file lookup (which matches on + * `context = 'workspace'`) resolves it to nothing. The row bound to the key is + * the only thing that separates the two, and it is server-authored at upload + * time, so it is as trustworthy as the prefix itself. + * + * An unbound key keeps its inferred context: absent metadata is not evidence of + * an attachment, and the caller's own not-found handling is the right answer. + */ +export async function resolveStoredFileContext(key: string): Promise { + const inferred = inferContextFromKey(key) + if (inferred !== 'workspace') return inferred + + const metadata = await getFileMetadataByKey(key) + return metadata?.context === 'mothership' ? 'mothership' : inferred +} + /** * Get active (non-deleted) file metadata for multiple keys in a single query. * Batches what would otherwise be N `getFileMetadataByKey` calls. From 60097c89b4f78eeb5e275e8d159e22e506680b5a Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 17 Aug 2026 16:25:58 -0700 Subject: [PATCH 07/26] fix(cli): default to the host that serves the API (#6791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sim.ai` answers /api/** with a 301 to `www.sim.ai`, and the client refuses to follow redirects — a 301 rewrites a POST into a bodyless GET, so following one turns a write into a silent no-op and hands the API key to whatever host Location names. Defaulting to the apex therefore failed every command for anyone who never set an endpoint. Before the refusal shipped it was quieter and worse: reads succeeded while writes did nothing. Also trims the provider catalogue from eleven inferred columns to seven. `docsUrl`, `helpText`, `requiresClientGeneratedCredentialId` and the nested `fields` are what you read once you have chosen a provider, not what you scan to choose one, and they pushed the table well past a terminal. Both ids stay: `credentials connect` names an OAuth provider by `serviceId`, `credentials create` matches a service account on `providerId`. --- .../content/docs/en/cli/authentication.mdx | 2 +- .../content/docs/en/cli/configuration.mdx | 4 ++-- .../content/docs/en/cli/troubleshooting.mdx | 2 +- packages/sim-cli/README.md | 4 ++-- .../sim-cli/src/commands/configure.test.ts | 4 ++-- packages/sim-cli/src/config/profile.test.ts | 17 +++++++++++++--- packages/sim-cli/src/config/profile.ts | 13 +++++++++++- .../sim-cli/src/contract/commands.test.ts | 18 +++++++++++++++++ packages/sim-cli/src/contract/commands.ts | 20 +++++++++++++++++++ 9 files changed, 72 insertions(+), 12 deletions(-) diff --git a/apps/docs/content/docs/en/cli/authentication.mdx b/apps/docs/content/docs/en/cli/authentication.mdx index 72ad91065b0..617015ef35c 100644 --- a/apps/docs/content/docs/en/cli/authentication.mdx +++ b/apps/docs/content/docs/en/cli/authentication.mdx @@ -20,7 +20,7 @@ The terminal prints a pairing code and a URL: Pairing code: K7M2-P9XT Confirm this code matches what the browser shows before approving. -https://sim.ai/cli/auth?request=…&scope=platform +https://www.sim.ai/cli/auth?request=…&scope=platform Waiting for approval… ✓ Logged in. Key stored in /Users/you/.sim/credentials diff --git a/apps/docs/content/docs/en/cli/configuration.mdx b/apps/docs/content/docs/en/cli/configuration.mdx index ed62657c6d8..564bc07cef6 100644 --- a/apps/docs/content/docs/en/cli/configuration.mdx +++ b/apps/docs/content/docs/en/cli/configuration.mdx @@ -52,7 +52,7 @@ Each setting resolves independently, and the first match wins: | 1 | Command-line flag — `--endpoint`, `--workspace`, `--output` | | 2 | Environment — `SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT` | | 3 | `~/.sim/config` and `~/.sim/credentials`, for the selected profile | -| 4 | Built-in default — `https://sim.ai` and `table` | +| 4 | Built-in default — `https://www.sim.ai` and `table` | `sim whoami` prints the winning source for each setting: @@ -67,7 +67,7 @@ repo: ```ini title="~/.sim/config" [default] -endpoint = https://sim.ai +endpoint = https://www.sim.ai workspace = ws_abc123 output = table diff --git a/apps/docs/content/docs/en/cli/troubleshooting.mdx b/apps/docs/content/docs/en/cli/troubleshooting.mdx index 4d3e2c59c73..c8ecdcef039 100644 --- a/apps/docs/content/docs/en/cli/troubleshooting.mdx +++ b/apps/docs/content/docs/en/cli/troubleshooting.mdx @@ -37,7 +37,7 @@ re-authenticating: sim whoami --profile ``` -## `Could not reach https://sim.ai: ` +## `Could not reach https://www.sim.ai: ` The request never got a response: DNS, TLS, a proxy, or a self-hosted stack that is not running. Confirm the endpoint the CLI actually used with `sim whoami`, and diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index dbbbbd1c9d8..f6c44366817 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -20,7 +20,7 @@ Non-secret settings live in `~/.sim/config`: ```ini [default] -endpoint = https://sim.ai +endpoint = https://www.sim.ai workspace = ws_abc123 output = table @@ -58,7 +58,7 @@ Each setting resolves independently, first match wins: | 1 | Command-line flag (`--endpoint`, `--workspace`, `--output`) | | 2 | Environment (`SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT`) | | 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | -| 4 | Built-in default (`https://sim.ai`, `table`) | +| 4 | Built-in default (`https://www.sim.ai`, `table`) | Formats are listed under [Output formats](#output-formats). diff --git a/packages/sim-cli/src/commands/configure.test.ts b/packages/sim-cli/src/commands/configure.test.ts index 4a4bbb64080..d06a956d541 100644 --- a/packages/sim-cli/src/commands/configure.test.ts +++ b/packages/sim-cli/src/commands/configure.test.ts @@ -33,14 +33,14 @@ afterEach(() => { describe('configure --set-endpoint', () => { it('refuses to store an endpoint that would later crash the URL parser', async () => { await expect(run('--set-endpoint', 'not-a-url')).rejects.toThrow( - 'Invalid endpoint "not-a-url" from --set-endpoint. Use an absolute URL, e.g. https://sim.ai or http://localhost:3000' + 'Invalid endpoint "not-a-url" from --set-endpoint. Use an absolute URL, e.g. https://www.sim.ai or http://localhost:3000' ) expect(readConfigProfile('default')).toEqual({}) }) it('refuses a scheme the HTTP client cannot speak', async () => { await expect(run('--set-endpoint', 'ftp://x.com')).rejects.toThrow( - 'Unsupported endpoint scheme "ftp" from --set-endpoint. Use http or https, e.g. https://sim.ai' + 'Unsupported endpoint scheme "ftp" from --set-endpoint. Use http or https, e.g. https://www.sim.ai' ) expect(readConfigProfile('default')).toEqual({}) }) diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 0e9d13d3d87..1d457be9b2a 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { configPath, credentialsPath } from './paths' import { + DEFAULT_ENDPOINT, deleteProfile, listProfiles, OUTPUT_FORMATS, @@ -31,7 +32,7 @@ describe('profile resolution', () => { it('falls back to built-in defaults with nothing configured', () => { const profile = resolveProfile() expect(profile.name).toBe('default') - expect(profile.endpoint).toBe('https://sim.ai') + expect(profile.endpoint).toBe('https://www.sim.ai') expect(profile.apiKey).toBeNull() expect(profile.output).toBe('table') expect(profile.sources.apiKey).toBe('unset') @@ -92,13 +93,23 @@ describe('profile resolution', () => { expect(resolveProfile({ profile: 'default' }).name).toBe('default') }) + it('defaults to the host that serves the API, not the apex that redirects to it', () => { + // `sim.ai` answers /api/** with a 301 to `www.sim.ai`, and the client + // refuses redirects because following one rewrites a POST into a bodyless + // GET. Defaulting to the apex therefore broke every command for anyone who + // never set an endpoint, so the host itself is the assertion. + expect(DEFAULT_ENDPOINT).toBe('https://www.sim.ai') + expect(new URL(DEFAULT_ENDPOINT).hostname).toBe('www.sim.ai') + expect(resolveProfile().endpoint).toBe(DEFAULT_ENDPOINT) + }) + it('strips a trailing slash so paths do not double up', () => { expect(resolveProfile({ endpoint: 'https://sim.ai///' }).endpoint).toBe('https://sim.ai') }) it('fails fast on an endpoint Node cannot parse, naming the source', () => { expect(() => resolveProfile({ endpoint: 'not-a-url' })).toThrow( - 'Invalid endpoint "not-a-url" from flag. Use an absolute URL, e.g. https://sim.ai or http://localhost:3000' + 'Invalid endpoint "not-a-url" from flag. Use an absolute URL, e.g. https://www.sim.ai or http://localhost:3000' ) process.env.SIM_ENDPOINT = 'not-a-url' @@ -111,7 +122,7 @@ describe('profile resolution', () => { it('rejects a parseable endpoint the HTTP client could never call', () => { expect(() => resolveProfile({ endpoint: 'ftp://x.com' })).toThrow( - 'Unsupported endpoint scheme "ftp" from flag. Use http or https, e.g. https://sim.ai' + 'Unsupported endpoint scheme "ftp" from flag. Use http or https, e.g. https://www.sim.ai' ) }) diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 8de5402d580..9a91e704401 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -12,7 +12,18 @@ import { import { configPath, credentialsPath } from './paths' export const DEFAULT_PROFILE = 'default' -export const DEFAULT_ENDPOINT = 'https://sim.ai' + +/** + * The API host, which is the `www` one and not the apex. + * + * `sim.ai` answers `/api/**` with a 301 to `www.sim.ai`, and the CLI refuses to + * follow a redirect — a 301 rewrites a POST into a bodyless GET, so following + * one turns a write into a silent no-op and hands the API key to whatever host + * `Location` names. Defaulting to the apex therefore made every command fail + * for anyone who never set an endpoint, and before the refusal existed it was + * worse: reads succeeded while writes quietly did nothing. + */ +export const DEFAULT_ENDPOINT = 'https://www.sim.ai' /** * Output formats, in the order `--help` lists them. diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index c537ecbaee3..8be7fa00cf6 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -175,4 +175,22 @@ describe('folder-path fields', () => { } expect(undecoded).toEqual([]) }) + + it('keeps the provider catalogue to what you scan to choose one', () => { + // Inferred, this listed eleven columns: the detail-view fields + // (`docsUrl`, `helpText`, `requiresClientGeneratedCredentialId`) pushed the + // table well past a terminal and read as empty on every OAuth row. + const columns = CLI_CONTRACT.listCredentialProviders?.columns ?? [] + const paths = columns.map((column) => column.path ?? column.header) + + expect(columns.length).toBeLessThanOrEqual(7) + for (const detail of ['docsUrl', 'helpText', 'requiresClientGeneratedCredentialId', 'fields']) { + expect(paths).not.toContain(detail) + } + // Both ids stay: `credentials connect` names an OAuth provider by + // `serviceId`, `credentials create` matches a service account on + // `providerId`, and the catalogue is where you look either up. + expect(paths).toContain('serviceId') + expect(paths).toContain('providerId') + }) }) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 8e522b3f37a..66622e9d38a 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -435,6 +435,26 @@ export const CLI_CONTRACT: CliContract = { { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, + // Inferred, this was eleven columns wide, four of them belonging to a detail + // view rather than a catalogue: `docsUrl`, `helpText`, + // `requiresClientGeneratedCredentialId` and the nested `fields` are what you + // read once you have chosen a provider, not what you scan to choose one. + // + // Both ids stay, because the next command takes one or the other and which + // depends on the row: `credentials connect` names an OAuth provider by + // `serviceId`, while `credentials create` matches a service-account provider + // on `providerId`. Each is empty on the kind of row that does not use it. + listCredentialProviders: { + columns: [ + { header: 'type' }, + { header: 'service', path: 'serviceId' }, + { header: 'provider', path: 'providerId' }, + { header: 'name' }, + { header: 'family', path: 'providerFamily' }, + { header: 'available', format: 'bool' }, + { header: 'description' }, + ], + }, listSecrets: { columns: [ { header: 'name' }, From 38075ad977f1951b7937bb4758448f5083662b9c Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 17 Aug 2026 16:27:23 -0700 Subject: [PATCH 08/26] fix(sap_concur): align the integration with SAP Concur's documented API (#6790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sap_concur): align the integration with SAP Concur's documented API Validated all 70 tools, the block, and both proxy routes against SAP's published API docs. Auth: - add the password and companyUuid to the token cache key so a request with the wrong password can no longer be served a cached token minted from someone else's - wire the documented company-level flow (username = company UUID, credtype = authtoken) so companyUuid actually scopes a token - expand the datacenter allowlist to the documented set (adds glz, apj1, usg, the impl hosts, and the www- twins) and drop the undocumented cn host; validate the returned geolocation by shape instead of membership - coalesce concurrent token fetches so a fan-out mints one token - forward Retry-After so 429 retries pace off Concur's own hint - handle the errorMessageList, SCIM detail, and legacy Error.Message shapes instead of falling through to a generic HTTP message - pin redirects and cap the response body Block: - collapse six contextType subBlocks that disagreed on their default, so a new block no longer seeds MANAGER for every operation - clamp contextType to each operation's documented set - stop requiring a userId and contextType that the default operation's tool does not accept, and scope the receipt fields to the upload ops - reach six params that had no subBlock, and pass userId on travel request updates so a stale value cannot impersonate Tools: - correct response shapes that resolved to undefined: budget headers, budget categories, allocations, receipts, SCIM nextCursor, and the delete endpoints that return a bare boolean - use the Travel Request Amount schema (currency, not currencyCode) - narrow the four XML-only travel tools to a documented string payload and request application/xml - surface real errors instead of a JSON parse failure when the proxy returns a non-JSON body - cap receipt uploads at the documented sizes before downloading Adds 106 tests covering the token cache, geolocation validation, path traversal, and error extraction. * fix(sap_concur): drop the removed forwardId subblock via a migration Removing the `forwardId` subblock without a migration entry breaks deployed workflows that still carry a value under that key. It fed a `concur-forwardid` request header that is documented nowhere in Concur's Receipts v4 or Image v1 references, so it was never honored. There is no replacement subblock and the value is an opaque caller-chosen string rather than a secret, so it is dropped outright. * fix(sap_concur): stop swallowing upload response-read failures The upload route caught every error from the bounded response read and continued down the success path, so a size-limit breach or a stream failure surfaced as an upstream success with a null or header-only body. Concur returns Content-Length: 0 on a successful image-only upload, and readResponseTextWithLimit already returns an empty string for that without throwing, so dropping the catch keeps the legitimate empty-body case working while letting real read failures reach the route's handler. * fix(sap_concur): unblock company auth and correct the body wand prompt The password grant marked username required, so the company-level flow — which sends the company UUID as the token username and has no user login — could not be configured at all, even though the request schema and token fetch already accept companyUuid without a username. Username is now optional for that grant and the server-side check reports which of the two is missing. Relabels the password and companyUuid fields to say what they carry in the company flow. The shared body wand prompt also still described several payloads the way they looked before this branch: quick expenses in PascalCase rather than v4 camelCase, travel requests and expected expenses using currencyCode where the Request v4 Amount schema uses currency, the standard SCIM SearchRequest URN instead of Concur's, startIndex as a search parameter when it is unsupported, and a cash advance shape that does not match the documented request. A wand-generated body was therefore rejected for most of the create operations it covers. * fix(sap_concur): keep Concur's status when an error body fails to read Removing the blanket catch from the upload read fixed one failure mode and introduced its inverse: a cap breach or stream error while reading a non-success body threw before the route reached the branch that preserves Concur's status, so an upstream 4xx surfaced as a Sim 500 and could trigger a retry the caller should not make. Both routes now split the two cases. On a success status the body is the result, so a read failure still propagates. On an error status the body only supplies the message, so a read failure resolves empty and the upstream status survives, with the message falling back to the generic HTTP-status form. Adds 21 tests covering both helpers over success, error, empty-body and boundary statuses; inverting the status check turns 14 of them red. --- .../docs/en/integrations/sap_concur.mdx | 547 ++++++----- .../app/api/tools/sap_concur/proxy/route.ts | 79 +- .../tools/sap_concur/response-body.test.ts | 130 +++ .../app/api/tools/sap_concur/shared.test.ts | 906 ++++++++++++++++++ apps/sim/app/api/tools/sap_concur/shared.ts | 462 ++++++++- .../app/api/tools/sap_concur/upload/route.ts | 216 ++++- apps/sim/blocks/blocks/sap_concur.ts | 718 ++++++++++++-- apps/sim/lib/integrations/integrations.json | 24 +- .../migrations/subblock-migrations.ts | 9 + apps/sim/tools/generated/tool-metadata.ts | 2 +- apps/sim/tools/generated/tool-outputs.ts | 2 +- apps/sim/tools/registry.ts | 6 +- .../sap_concur/approve_expense_report.ts | 6 +- .../tools/sap_concur/associate_attendees.ts | 3 +- .../tools/sap_concur/create_cash_advance.ts | 3 +- .../sap_concur/create_expected_expense.ts | 8 +- apps/sim/tools/sap_concur/create_list_item.ts | 5 + .../sap_concur/create_purchase_request.ts | 3 +- .../tools/sap_concur/create_quick_expense.ts | 4 +- .../create_quick_expense_with_image.ts | 6 +- .../tools/sap_concur/create_report_comment.ts | 2 +- .../tools/sap_concur/create_travel_request.ts | 24 +- apps/sim/tools/sap_concur/create_user.ts | 3 +- .../sap_concur/delete_expected_expense.ts | 6 +- apps/sim/tools/sap_concur/delete_expense.ts | 1 - apps/sim/tools/sap_concur/delete_list_item.ts | 4 +- .../tools/sap_concur/delete_travel_request.ts | 11 +- apps/sim/tools/sap_concur/delete_user.ts | 4 +- apps/sim/tools/sap_concur/get_allocation.ts | 3 + apps/sim/tools/sap_concur/get_budget.ts | 2 +- apps/sim/tools/sap_concur/get_cash_advance.ts | 6 +- .../tools/sap_concur/get_expected_expense.ts | 6 +- apps/sim/tools/sap_concur/get_expense.ts | 13 +- .../tools/sap_concur/get_expense_report.ts | 3 +- apps/sim/tools/sap_concur/get_itemizations.ts | 8 +- apps/sim/tools/sap_concur/get_itinerary.ts | 136 +-- apps/sim/tools/sap_concur/get_list_item.ts | 5 + .../tools/sap_concur/get_purchase_request.ts | 3 +- .../sap_concur/get_request_cash_advance.ts | 24 +- .../tools/sap_concur/get_travel_profile.ts | 146 +-- .../tools/sap_concur/get_travel_request.ts | 106 +- .../tools/sap_concur/issue_cash_advance.ts | 3 +- apps/sim/tools/sap_concur/list_allocations.ts | 74 +- .../sap_concur/list_budget_categories.ts | 45 +- apps/sim/tools/sap_concur/list_budgets.ts | 37 +- apps/sim/tools/sap_concur/list_exceptions.ts | 9 + .../sap_concur/list_expected_expenses.ts | 81 +- .../tools/sap_concur/list_expense_reports.ts | 11 +- apps/sim/tools/sap_concur/list_expenses.ts | 12 +- apps/sim/tools/sap_concur/list_itineraries.ts | 110 +-- apps/sim/tools/sap_concur/list_list_items.ts | 10 +- apps/sim/tools/sap_concur/list_lists.ts | 16 +- apps/sim/tools/sap_concur/list_receipts.ts | 60 +- .../tools/sap_concur/list_report_comments.ts | 8 +- .../list_travel_profiles_summary.ts | 100 +- .../tools/sap_concur/list_travel_requests.ts | 49 +- apps/sim/tools/sap_concur/list_users.ts | 2 +- .../tools/sap_concur/move_travel_request.ts | 219 ++++- .../tools/sap_concur/recall_expense_report.ts | 13 +- .../tools/sap_concur/remove_all_attendees.ts | 1 - apps/sim/tools/sap_concur/search_locations.ts | 121 ++- apps/sim/tools/sap_concur/search_users.ts | 2 +- .../tools/sap_concur/submit_expense_report.ts | 10 +- apps/sim/tools/sap_concur/types.ts | 112 +-- .../sim/tools/sap_concur/update_allocation.ts | 4 +- .../sap_concur/update_expected_expense.ts | 6 +- apps/sim/tools/sap_concur/update_expense.ts | 3 +- .../tools/sap_concur/update_expense_report.ts | 5 +- apps/sim/tools/sap_concur/update_list_item.ts | 5 + .../tools/sap_concur/update_travel_request.ts | 14 +- apps/sim/tools/sap_concur/update_user.ts | 3 +- .../tools/sap_concur/upload_receipt_image.ts | 16 +- apps/sim/tools/sap_concur/utils.ts | 148 ++- 73 files changed, 3724 insertions(+), 1240 deletions(-) create mode 100644 apps/sim/app/api/tools/sap_concur/response-body.test.ts create mode 100644 apps/sim/app/api/tools/sap_concur/shared.test.ts diff --git a/apps/docs/content/docs/en/integrations/sap_concur.mdx b/apps/docs/content/docs/en/integrations/sap_concur.mdx index 8d45db8825b..e908e395d79 100644 --- a/apps/docs/content/docs/en/integrations/sap_concur.mdx +++ b/apps/docs/content/docs/en/integrations/sap_concur.mdx @@ -34,7 +34,7 @@ These capabilities let you eliminate manual data entry, accelerate close cycles, ## Usage Instructions -Connect SAP Concur via OAuth 2.0. Manage expense reports and line items, allocations, attendees, comments, exceptions, quick expenses, receipts, travel requests and expected expenses, cash advances, itineraries, user identities, custom lists, budgets, exchange rates, and purchase requests across every Concur datacenter. +Connect SAP Concur with an OAuth client ID and secret (client-credentials or password grant) — no account linking required. Manage expense reports and line items, allocations, attendees, comments, exceptions, quick expenses, receipts, travel requests and expected expenses, cash advances, itineraries, user identities, custom lists, budgets, exchange rates, and purchase requests across every Concur datacenter. @@ -42,7 +42,7 @@ Connect SAP Concur via OAuth 2.0. Manage expense reports and line items, allocat ### SAP Concur Approve Expense Report -Approve an expense report as a manager (PATCH /expensereports/v4/reports/\{reportId\}/approve). Required body field: comment. +Approve an expense report as a manager (PATCH /expensereports/v4/reports/\{reportId\}/approve). Optional body fields: comment, expenseRejectedComment (required if the report has rejected expenses), expectedStepCode, expectedStepSequence, statusId (default A_APPR). #### Input @@ -56,7 +56,7 @@ Approve an expense report as a manager (PATCH /expensereports/v4/reports/\{repor | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `reportId` | string | Yes | Expense report ID to approve | -| `body` | json | Yes | Request body — `comment` is required by Concur \(e.g., \{ "comment": "Approved" \}\). If the report contains rejected expenses, `expenseRejectedComment` is also required. Optional fields: `expectedStepCode`, `expectedStepSequence`, `statusId` \(defaults to "A_APPR"\). | +| `body` | json | No | Optional request body. All fields are optional: `comment` \(e.g., \{ "comment": "Approved" \}\), `expenseRejectedComment` \(required only if the report contains rejected expenses\), `expectedStepCode`, `expectedStepSequence`, `statusId` \(defaults to "A_APPR"\). | #### Output @@ -84,7 +84,7 @@ Associate attendees with an expense (POST /expensereports/v4/users/\{userId\}/co | `contextType` | string | Yes | Access context: TRAVELER or PROXY | | `reportId` | string | Yes | Expense report ID | | `expenseId` | string | Yes | Expense ID | -| `body` | json | Yes | Attendee associations payload \(e.g., \{ "attendeeAssociations": \[...\] \}\) | +| `body` | json | Yes | Attendee association payload with exactly two top-level fields: "noShowAttendeeCount" \(integer, default 0\) and "expenseAttendeeList" \(array\). Each entry in expenseAttendeeList requires "attendeeId" \(string\) and "transactionAmount" \(object: \{ "value": number, "currencyCode": string \}\), and optionally accepts "customData", "isAmountUserEdited" \(boolean\), "isTraveling" \(boolean\), "associatedAttendeeCount" \(integer\), and "versionNumber" \(integer\). Example: \{ "noShowAttendeeCount": 0, "expenseAttendeeList": \[\{ "attendeeId": "gWmMv2Ii5rGtEBTBhBqUw", "transactionAmount": \{ "value": 23, "currencyCode": "USD" \} \}\] \}. Note: the object form follows the request schema \(Amount = value + currencyCode, both required\), but the documented POST example sends a scalar "transactionAmount": 23 — the docs conflict here. | #### Output @@ -109,7 +109,7 @@ Create a cash advance (POST /cashadvance/v4.1/cashadvances). | `username` | string | No | Username \(only for password grant\) | | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | -| `body` | json | Yes | Cash advance payload | +| `body` | json | Yes | Cash advance payload. Required fields: amountRequested \(\{ currency, amount \}\), name, and userId. Optional fields: accountCode, comment, purpose. The Concur docs are inconsistent on casing — the reference request example and the API Explorer swagger both use userId, while the schema table spells it userID; if a request is rejected with a 400, retry with the other spelling. | #### Output @@ -148,14 +148,14 @@ Create an expected expense on a travel request (POST /travelrequest/v4/requests/ | ↳ `href` | string | Self-link to the resource | | ↳ `expenseType` | json | Expense type \{id, name\} | | ↳ `transactionDate` | string | Transaction date | -| ↳ `transactionAmount` | json | Transaction amount \{value, currencyCode\} | -| ↳ `postedAmount` | json | Posted amount \{value, currencyCode\} | -| ↳ `approvedAmount` | json | Approved amount \{value, currencyCode\} | +| ↳ `transactionAmount` | json | Transaction amount \{value, currency\} | +| ↳ `postedAmount` | json | Posted amount \{value, currency\} | +| ↳ `approvedAmount` | json | Approved amount \{value, currency\} | | ↳ `remainingAmount` | json | Remaining amount on the expected expense | | ↳ `businessPurpose` | string | Business purpose of the expense | | ↳ `location` | json | Location \{id, name, city, countryCode, countrySubDivisionCode, iataCode, locationType\} | | ↳ `exchangeRate` | json | Exchange rate \{value, operation\} | -| ↳ `allocations` | json | Budget allocations array \(allocationId, allocationAmount, approvedAmount, postedAmount, expenseId, percentEdited, systemAllocation, percentage\) | +| ↳ `allocations` | json | Budget allocations array \(allocationId, allocationAmount \{value, currency\}, approvedAmount \{value, currency\}, postedAmount \{value, currency\}, expenseId, percentEdited, systemAllocation, percentage\) | | ↳ `tripData` | json | Trip data \{agencyBooked, selfBooked, tripType \(ONE_WAY\|ROUND_TRIP\), legs\[\{id, returnLeg, startDate, startTime, startLocationDetail, startLocation, endLocation, class \{code,value\}, travelExceptionReasonCodes\}\], segmentType \{category, code\}\} | | ↳ `parentRequest` | json | Parent travel request resource link \{href, id\} | | ↳ `comments` | json | Comments sub-resource link \{href, id\} | @@ -211,6 +211,7 @@ Create a list item (POST /list/v4/items). | `status` | number | HTTP status code returned by Concur | | `data` | json | Created list item | | ↳ `id` | string | List item UUID | +| ↳ `listId` | string | UUID of the list that contains the list item | | ↳ `code` | string | Long code format for the item | | ↳ `shortCode` | string | Short code identifier | | ↳ `value` | string | Display value of the item | @@ -236,7 +237,7 @@ Create a purchase request (POST /purchaserequest/v4/purchaserequests). | `username` | string | No | Username \(only for password grant\) | | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | -| `body` | json | Yes | Purchase request payload | +| `body` | json | Yes | Purchase request payload. Required: exactly one of userId, userEmail, or userLoginId; currencyCode \(ISO 4217\); and lineItems\[\]. Each line item requires purchaseType \(GOODS or SERVICES\), vendorCode, vendorAddressCode, description, quantity, and unitPrice. | #### Output @@ -253,7 +254,7 @@ Create a purchase request (POST /purchaserequest/v4/purchaserequests). ### SAP Concur Create Quick Expense -Create a quick expense (POST /quickexpense/v4/users/\{userId\}/context/TRAVELER/quickexpenses). +Create a quick expense (POST /quickexpense/v4/users/\{userId\}/context/\{contextType\}/quickexpenses). TRAVELER is the only supported context type. #### Input @@ -268,7 +269,7 @@ Create a quick expense (POST /quickexpense/v4/users/\{userId\}/context/TRAVELER/ | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userId` | string | Yes | Concur user UUID who owns the quick expense | | `contextType` | string | Yes | Access context: must be TRAVELER | -| `body` | json | Yes | Quick expense payload \(expenseTypeId, transactionAmount, transactionDate, etc.\) | +| `body` | json | Yes | Quick expense payload. Required: expenseTypeId, transactionAmount \{currencyCode, value\}, transactionDate \(YYYY-MM-DD\). Optional: comment, entryDetails, location \{city, countryCode, countrySubDivisionCode, id, name\}, paymentTypeId \(CASHX \| CPAID \| PENDC\), vendor. | #### Output @@ -295,8 +296,8 @@ Create a quick expense with an attached image (POST /quickexpense/v4/users/\{use | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userId` | string | Yes | Concur user UUID | | `contextType` | string | Yes | Access context: must be TRAVELER | -| `receipt` | json | Yes | Receipt image \(UserFile\). Allowed: PDF, PNG, JPEG, TIFF \(max 50MB\) | -| `body` | json | Yes | Quick expense payload \(transactionAmount, transactionDate, expenseTypeId, vendor, ...\) | +| `receipt` | json | Yes | Receipt image \(UserFile\). Allowed: PNG, PDF, TIFF, JPEG. Maximum size 50 MB | +| `body` | json | Yes | Quick expense payload. Required: expenseTypeId, transactionAmount \{currencyCode, value\}, transactionDate \(YYYY-MM-DD\). Optional: comment, entryDetails, location \{city, countryCode, countrySubDivisionCode, id, name\}, paymentTypeId \(CASHX \| CPAID \| PENDC\), vendor. | #### Output @@ -322,7 +323,7 @@ Create a comment on a report (POST /expensereports/v4/users/\{userId\}/context/\ | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userId` | string | Yes | Concur user UUID | -| `contextType` | string | Yes | Access context: TRAVELER or PROXY | +| `contextType` | string | Yes | Access context: TRAVELER, MANAGER, or PROXY | | `reportId` | string | Yes | Expense report ID | | `comment` | string | Yes | Comment text to add | @@ -349,8 +350,8 @@ Create a travel request (POST /travelrequest/v4/requests). | `username` | string | No | Username \(only for password grant\) | | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | -| `userId` | string | No | Optional Concur user UUID — required when impersonating another user | -| `body` | json | Yes | Travel request payload \(name, purpose, startDate, endDate, requestPolicyId, etc.\) | +| `userId` | string | No | Concur user UUID of the Request owner — required when using the default `client_credentials` \(company\) grant; omitting it returns 400 `missingRequiredParam`. | +| `body` | json | Yes | Travel request payload. Supported fields: name, businessPurpose, startDate/endDate \(YYYY-MM-DD\), startTime/endTime \(HH:mm\), mainDestination \(\{ city, countryCode, countrySubDivisionCode, name \}\), policy \(\{ id \}\), and custom1-custom20 \(\{ value \} or \{ code, value \}\). An id field is not allowed. | #### Output @@ -420,6 +421,22 @@ Create a travel request (POST /travelrequest/v4/requests). | ↳ `custom2` | json | Custom field 2 | | ↳ `custom3` | json | Custom field 3 | | ↳ `custom4` | json | Custom field 4 | +| ↳ `custom5` | json | Custom field 5 | +| ↳ `custom6` | json | Custom field 6 | +| ↳ `custom7` | json | Custom field 7 | +| ↳ `custom8` | json | Custom field 8 | +| ↳ `custom9` | json | Custom field 9 | +| ↳ `custom10` | json | Custom field 10 | +| ↳ `custom11` | json | Custom field 11 | +| ↳ `custom12` | json | Custom field 12 | +| ↳ `custom13` | json | Custom field 13 | +| ↳ `custom14` | json | Custom field 14 | +| ↳ `custom15` | json | Custom field 15 | +| ↳ `custom16` | json | Custom field 16 | +| ↳ `custom17` | json | Custom field 17 | +| ↳ `custom18` | json | Custom field 18 | +| ↳ `custom19` | json | Custom field 19 | +| ↳ `custom20` | json | Custom field 20 | ### SAP Concur Create User @@ -436,7 +453,7 @@ Create a new user identity (POST /profile/identity/v4.1/Users). | `username` | string | No | Username \(only for password grant\) | | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | -| `body` | json | Yes | SCIM User payload \(schemas, userName, name, emails, active, etc.\) | +| `body` | json | Yes | SCIM User payload. Required: schemas \(include both "urn:ietf:params:scim:schemas:core:2.0:User" and "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"\), userName, name.familyName, name.givenName, emails\[\].value, and companyId — which is required and immutable and must be set inside the "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" block, not at the top level. Optional: active, displayName, timezone, and other SCIM User attributes. | #### Output @@ -468,7 +485,7 @@ Delete an expected expense (DELETE /travelrequest/v4/expenses/\{expenseUuid\}). | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | json | Returns boolean true on 200 OK when the expected expense is deleted. | +| `data` | boolean | true when the expected expense was deleted | ### SAP Concur Delete Expense @@ -521,7 +538,7 @@ Delete an expense report (DELETE /expensereports/v4/reports/\{reportId\}). ### SAP Concur Delete List Item -Delete a list item (DELETE /list/v4/items/\{itemId\}). +Delete a list item from all lists that contain it (DELETE /list/v4/items/\{itemId\}). This is not scoped to a single list, and all children of that list item are also deleted. #### Input @@ -559,18 +576,18 @@ Delete a travel request (DELETE /travelrequest/v4/requests/\{requestUuid\}). | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `requestUuid` | string | Yes | Travel request UUID to delete | -| `userId` | string | No | Optional Concur user UUID — required when impersonating another user | +| `userId` | string | No | Concur user UUID of the Request owner — required when using the default `client_credentials` \(company\) grant; omitting it returns 400 `missingRequiredParam`. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | json | Concur delete response payload \(boolean true on 200 OK\) | +| `data` | boolean | Concur delete response body — literally true on 200 OK | ### SAP Concur Delete User -Delete a user identity (DELETE /profile/identity/v4.1/Users/\{id\}). +Hard delete a user identity (DELETE /profile/identity/v4.1/Users/\{id\}). Not recommended: SAP restricts hard delete to users with no transaction history and governs it by the Concur Data Retention policy. To deactivate a user instead, use SAP Concur Update User with a PATCH replacing active with false. #### Input @@ -651,7 +668,7 @@ Get a budget item header by ID (GET /budget/v4/budgetItemHeader/\{id\}). | `username` | string | No | Username \(only for password grant\) | | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | -| `budgetId` | string | Yes | Budget item header ID \(syncguid\) | +| `budgetId` | string | Yes | The budget item header's key field \(uuid\) | #### Output @@ -788,9 +805,9 @@ Get an expected expense (GET /travelrequest/v4/expenses/\{expenseUuid\}). | ↳ `href` | string | Self-link | | ↳ `expenseType` | json | Expense type \{id, name\} | | ↳ `transactionDate` | string | Transaction date | -| ↳ `transactionAmount` | json | Transaction amount \{value, currencyCode\} | -| ↳ `postedAmount` | json | Posted amount \{value, currencyCode\} | -| ↳ `approvedAmount` | json | Approved amount \{value, currencyCode\} | +| ↳ `transactionAmount` | json | Transaction amount \{value, currency\} | +| ↳ `postedAmount` | json | Posted amount \{value, currency\} | +| ↳ `approvedAmount` | json | Approved amount \{value, currency\} | | ↳ `remainingAmount` | json | Remaining amount on the expected expense | | ↳ `businessPurpose` | string | Business purpose of the expense | | ↳ `location` | json | Location \{id, name, city, countryCode, countrySubDivisionCode, iataCode, locationType\} | @@ -831,7 +848,6 @@ Get a single expense (GET /expensereports/v4/users/\{userId\}/context/\{contextT | ↳ `allocationState` | string | FULLY_ALLOCATED, NOT_ALLOCATED, or PARTIALLY_ALLOCATED | | ↳ `expenseType` | json | Expense type \{id, name, code, isDeleted\} | | ↳ `paymentType` | json | Payment type \{id, name, code\} | -| ↳ `expenseSource` | string | Source of the expense \(CASH, CCARD, EBOOKING, etc.\) | | ↳ `transactionDate` | string | Transaction date \(YYYY-MM-DD\) | | ↳ `budgetAccrualDate` | string | Budget accrual date | | ↳ `transactionAmount` | json | Transaction amount \{currencyCode, value\} | @@ -843,7 +859,6 @@ Get a single expense (GET /expensereports/v4/users/\{userId\}/context/\{contextT | ↳ `vendor` | json | Vendor info \{id, name, description\} | | ↳ `location` | json | Location \{id, name, city, countryCode, countrySubDivisionCode\} | | ↳ `businessPurpose` | string | Business purpose | -| ↳ `comment` | string | Free-form comment associated with the expense | | ↳ `isExpenseBillable` | boolean | Billable flag | | ↳ `isPersonalExpense` | boolean | Personal-expense flag | | ↳ `isExpenseRejected` | boolean | Whether the expense was rejected | @@ -876,7 +891,7 @@ Get a single expense (GET /expensereports/v4/users/\{userId\}/context/\{contextT | ↳ `governmentInvoiceId` | string | Government invoice identifier | | ↳ `lastModifiedDate` | string | Last modified timestamp | | ↳ `expenseSourceIdentifiers` | json | Source reference identifiers | -| ↳ `links` | json | HATEOAS links for the expense | +| ↳ `links` | array | HATEOAS links for the expense | ### SAP Concur Get Expense Report @@ -968,7 +983,7 @@ Retrieve a single expense report header by id via Expense Report v4 (/expenserep | ↳ `submitterId` | string | Submitter user ID | | ↳ `taxConfigId` | string | Tax configuration ID | | ↳ `redirectFund` | json | Redirect fund object \{ amount, creditCardId \} | -| ↳ `customData` | array | Array of custom data \{ id, value, isValid, listItemUrl \} | +| ↳ `customData` | array | Array of custom data \{ id, value, isValid \}. Responses may additionally carry a response-only listItemUrl, which can be null | | ↳ `employee` | json | Employee object \{ employeeId, employeeUuid \} | | ↳ `links` | array | HATEOAS links | @@ -988,7 +1003,7 @@ Get expense itemizations (GET /expensereports/v4/users/\{userId\}/context/\{cont | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userId` | string | Yes | Concur user UUID | -| `contextType` | string | Yes | Access context: TRAVELER, MANAGER, or PROXY | +| `contextType` | string | Yes | Access context: TRAVELER \(the only value the endpoint supports\) | | `reportId` | string | Yes | Expense report ID | | `expenseId` | string | Yes | Expense ID | @@ -998,9 +1013,7 @@ Get expense itemizations (GET /expensereports/v4/users/\{userId\}/context/\{cont | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | | `data` | array | Array of itemizations \(ReportExpenseSummary\[\]\) | -| ↳ `id` | string | Itemization identifier | | ↳ `expenseId` | string | Itemization expense id | -| ↳ `allocations` | array | Allocations applied to the itemization | | ↳ `expenseType` | json | Expense type \{id, name, code, isDeleted\} | | ↳ `transactionDate` | string | Transaction date \(YYYY-MM-DD\) | | ↳ `transactionAmount` | json | Transaction amount | @@ -1036,39 +1049,16 @@ Get a single trip/itinerary (GET /api/travel/trip/v1.1/\{tripID\}). | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `tripId` | string | Yes | Trip ID | -| `useridType` | string | No | User identifier type \(login, xmlsyncid, uuid\) | +| `useridType` | string | No | User identifier type. The only value documented for Trips v1.1 is "login" \(the value is the user login id\); xmlsyncid and uuid are Travel Profile v2 identifier types and are not documented for this endpoint. | | `useridValue` | string | No | User identifier value \(paired with useridType\) | -| `systemFormat` | string | No | Optional system format \(e.g., GDS\) for the response | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `status` | number | HTTP status code returned by Concur | -| `data` | json | Trip detail payload \(Itinerary v1.1\) | -| ↳ `ItinLocator` | string | Concur trip locator \(trip ID\) | -| ↳ `ClientLocator` | string | Client \(booking source\) trip locator | -| ↳ `ItinSourceName` | string | Booking source name | -| ↳ `BookedVia` | string | How the trip was booked \(e.g. ConcurTravel, Direct\) | -| ↳ `TripName` | string | Trip name | -| ↳ `Status` | string | Trip status \(e.g. Confirmed, Cancelled\) | -| ↳ `Description` | string | Trip description | -| ↳ `Comments` | string | Comments attached to the trip | -| ↳ `CancelComments` | string | Cancellation comments \(when applicable\) | -| ↳ `ProjectName` | string | Associated project name | -| ↳ `StartDateUtc` | string | Trip start datetime in UTC | -| ↳ `EndDateUtc` | string | Trip end datetime in UTC | -| ↳ `StartDateLocal` | string | Trip start datetime in local time | -| ↳ `EndDateLocal` | string | Trip end datetime in local time | -| ↳ `DateCreatedUtc` | string | Trip creation timestamp \(UTC\) | -| ↳ `DateModifiedUtc` | string | Trip last-modified timestamp \(UTC\) | -| ↳ `DateBookedLocal` | string | Booking date in local time | -| ↳ `UserLoginId` | string | Login id of the trip owner | -| ↳ `BookedByFirstName` | string | First name of the booker | -| ↳ `BookedByLastName` | string | Last name of the booker | -| ↳ `IsPersonal` | boolean | Whether the trip is flagged personal | -| ↳ `RuleViolations` | array | Travel rule violations attached to the trip | -| ↳ `Bookings` | array | Bookings \(air/hotel/car/rail\) attached to the trip | +| `systemFormat` | string | No | Optional response format. The only supported value is "Tripit", which returns a completely different XML document rooted at <Response><Trip> instead of the standard itinerary document. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by Concur | +| `data` | string | Raw XML trip document returned by Concur \(Trips v1.1 emits application/xml only, so this is a string and not a parsed object\). The document is rooted at <Itinerary> and contains id, ItinLocator, ClientLocator, ItinSourceName, BookedVia, TripName, Status, Description, Comments, CancelComments, ProjectName, StartDateUtc, EndDateUtc, StartDateLocal, EndDateLocal, DateCreatedUtc, DateModifiedUtc, DateBookedLocal, BookedByFirstName, BookedByLastName, IsPersonal, RuleViolations, and Bookings > Booking. When systemFormat=Tripit is passed the document is rooted at <Response><Trip> instead. | ### SAP Concur Get List @@ -1130,6 +1120,7 @@ Get a single list item (GET /list/v4/items/\{itemId\}). | `status` | number | HTTP status code returned by Concur | | `data` | json | List item detail payload | | ↳ `id` | string | List item UUID | +| ↳ `listId` | string | UUID of the list that contains the list item | | ↳ `code` | string | Long code format for the item | | ↳ `shortCode` | string | Short code identifier | | ↳ `value` | string | Display value of the item | @@ -1260,33 +1251,7 @@ Get a travel profile (GET /api/travelprofile/v2.0/profile). Returns the calling | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | json | Travel profile payload. Concur returns XML; downstream may parse it to a best-effort JSON object with the documented top-level sections. | -| ↳ `General` | json | General profile info \(NamePrefix, FirstName, MiddleName, LastName, NameSuffix, JobTitle, CompanyEmployeeID, EmailAddress, RuleClass, TravelConfigID, etc.\) | -| ↳ `Telephones` | json | Telephone numbers \(Telephone\[\] with Type, CountryCode, PhoneNumber, etc.\) | -| ↳ `Addresses` | json | Address records \(Address\[\] with Type, Street, City, StateProvince, etc.\) | -| ↳ `DriversLicenses` | array | Drivers license records | -| ↳ `NationalIDs` | array | National ID records | -| ↳ `EmailAddresses` | json | Email addresses \(EmailAddress\[\] with Type, Address, Contact, Verified\) | -| ↳ `EmergencyContact` | json | Emergency contact \(Name, Relationship, Phones, Address\) | -| ↳ `Air` | json | Air travel preferences \(HomeAirport, Seat, Meal, AirOther, AirMemberships\) | -| ↳ `Rail` | json | Rail preferences \(Seat, Coach, Berth, Other, RailMemberships\) | -| ↳ `Hotel` | json | Hotel preferences \(SmokingCode, RoomType, HotelOther, HotelMemberships, Accessibility flags\) | -| ↳ `Car` | json | Car rental preferences \(CarSmokingCode, CarType, CarMemberships, etc.\) | -| ↳ `CustomFields` | json | Custom-defined fields configured by the company | -| ↳ `RatePreferences` | json | Rate preferences \(e.g. AAA, AARP, government, military rates\) | -| ↳ `DiscountCodes` | json | Discount codes available to the traveler | -| ↳ `HasNoPassport` | boolean | Whether the traveler has no passport on file | -| ↳ `Roles` | json | Role assignments \(TravelManager, Assistant, etc.\) | -| ↳ `Sponsors` | json | Sponsor information for guest travelers | -| ↳ `TSAInfo` | json | TSA SecureFlight info \(Gender, DateOfBirth, NoMiddleName, etc.\) | -| ↳ `Passports` | json | Passport documents \(Passport\[\] with PassportNumber, Country, Expiration\) | -| ↳ `Visas` | json | Visa documents \(Visa\[\] with VisaNationality, VisaNumber, etc.\) | -| ↳ `UnusedTickets` | json | Unused ticket records | -| ↳ `SouthwestUnusedTickets` | json | Southwest-specific unused ticket records | -| ↳ `AdvantageMemberships` | json | Advantage program memberships | -| ↳ `XmlSyncId` | string | XML sync identifier for the user | -| ↳ `LoginId` | string | Concur login id | -| ↳ `ProfileLastModifiedUTC` | string | UTC timestamp the profile was last modified | +| `data` | string | Raw XML travel profile document returned by Concur \(Travel Profile v2 emits application/xml only, per the TravelUserProfile.xsd schema, so this is a string and not a parsed object\). The Profile root element contains General, EmergencyContact, Telephones, Addresses, NationalIDs, DriversLicenses, HasNoPassport, Passports, Visas, EmailAddresses, RatePreferences, DiscountCodes, Air, Rail, Car, Hotel, CustomFields, Roles, Sponsors, TSAInfo, UnusedTickets, SouthwestUnusedTickets, and AdvantageMemberships. LoginId is an attribute of the <ProfileResponse> element returned by create/update, not a child element; XmlProfileSyncID and ProfileLastModifiedUTC belong to the Travel Profile summaries \(ProfileSummary\) response, not to this document. | ### SAP Concur Get Travel Request @@ -1304,7 +1269,7 @@ Get a single travel request (GET /travelrequest/v4/requests/\{requestUuid\}). | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `requestUuid` | string | Yes | Travel request UUID | -| `userId` | string | No | Optional Concur user UUID — required when impersonating another user | +| `userId` | string | No | The unique identifier of the user getting the content of the Request. If empty when using a Company token the default system user will be assumed to perform the action. | #### Output @@ -1327,15 +1292,11 @@ Get a single travel request (GET /travelrequest/v4/requests/\{requestUuid\}). | ↳ `endDate` | string | Trip end date \(ISO 8601\) | | ↳ `startTime` | string | Trip start time \(HH:mm\) | | ↳ `endTime` | string | Trip end time \(HH:mm\) | -| ↳ `pnr` | string | Passenger record number | | ↳ `approved` | boolean | Whether the request is approved | | ↳ `pendingApproval` | boolean | Pending approval flag | | ↳ `closed` | boolean | Closed flag | | ↳ `everSentBack` | boolean | Ever-sent-back flag | | ↳ `canceledPostApproval` | boolean | Canceled after approval flag | -| ↳ `isParentRequest` | boolean | Parent request flag | -| ↳ `parentRequestId` | string | Parent budget request ID | -| ↳ `allocationFormId` | string | Allocation form identifier | | ↳ `highestExceptionLevel` | string | Highest exception level \(WARNING, ERROR, NONE\) | | ↳ `approvalStatus` | json | Approval status | | ↳ `code` | string | Status code \(NOT_SUBMITTED, SUBMITTED, APPROVED, CANCELED, SENTBACK\) | @@ -1381,12 +1342,23 @@ Get a single travel request (GET /travelrequest/v4/requests/\{requestUuid\}). | ↳ `travelAgency` | json | Resource link to travel agency | | ↳ `id` | string | Resource ID | | ↳ `href` | string | Resource hyperlink | -| ↳ `parentRequest` | json | Resource link to parent request | -| ↳ `id` | string | Resource ID | -| ↳ `href` | string | Resource hyperlink | -| ↳ `eventRequest` | json | Resource link to parent event request | -| ↳ `id` | string | Resource ID | -| ↳ `href` | string | Resource hyperlink | +| ↳ `extensionOf` | json | The Request for which this Request is an extension of, or addendum to | +| ↳ `requestId` | string | The public key of the Request \(unique per customer\) | +| ↳ `id` | string | Unique identifier of the Request | +| ↳ `href` | string | Hyperlink to the resource | +| ↳ `template` | string | Hyperlink template to the resource | +| ↳ `pnr` | string | The value of the pnr provided within the agency proposals by the travel agency | +| ↳ `isParentRequest` | boolean | Indicates whether this Request is a Budget Request | +| ↳ `parentRequestId` | string | Required if a Child Request is created, corresponds to the unique identifier of the Budget Request the Child Request will be linked to | +| ↳ `allocationFormId` | string | The unique identifier of the allocation form | +| ↳ `parentRequest` | json | If the Request is a Child Request, reference to the corresponding Budget Request | +| ↳ `id` | string | Unique identifier of the related object | +| ↳ `href` | string | Hyperlink to the resource | +| ↳ `template` | string | Hyperlink template to the resource | +| ↳ `eventRequest` | json | The parent Event Request to which this child Request is related | +| ↳ `id` | string | Unique identifier of the related object | +| ↳ `href` | string | Hyperlink to the resource | +| ↳ `template` | string | Hyperlink template to the resource | | ↳ `operations` | array | Available workflow actions | | ↳ `rel` | string | Operation name | | ↳ `href` | string | Operation URL | @@ -1397,6 +1369,22 @@ Get a single travel request (GET /travelrequest/v4/requests/\{requestUuid\}). | ↳ `custom2` | json | Custom field 2 | | ↳ `custom3` | json | Custom field 3 | | ↳ `custom4` | json | Custom field 4 | +| ↳ `custom5` | json | Custom field 5 | +| ↳ `custom6` | json | Custom field 6 | +| ↳ `custom7` | json | Custom field 7 | +| ↳ `custom8` | json | Custom field 8 | +| ↳ `custom9` | json | Custom field 9 | +| ↳ `custom10` | json | Custom field 10 | +| ↳ `custom11` | json | Custom field 11 | +| ↳ `custom12` | json | Custom field 12 | +| ↳ `custom13` | json | Custom field 13 | +| ↳ `custom14` | json | Custom field 14 | +| ↳ `custom15` | json | Custom field 15 | +| ↳ `custom16` | json | Custom field 16 | +| ↳ `custom17` | json | Custom field 17 | +| ↳ `custom18` | json | Custom field 18 | +| ↳ `custom19` | json | Custom field 19 | +| ↳ `custom20` | json | Custom field 20 | ### SAP Concur Get User @@ -1440,7 +1428,7 @@ Issue a cash advance (POST /cashadvance/v4.1/cashadvances/\{cashAdvanceId\}/issu | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `cashAdvanceId` | string | Yes | Cash advance ID to issue | -| `body` | json | No | Optional request body | +| `body` | json | No | Optional request body. All documented fields are optional: accountCode, comment, and exchangeRate. | #### Output @@ -1469,7 +1457,7 @@ List allocations on an expense (GET /expensereports/v4/users/\{userId\}/context/ | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userId` | string | Yes | Concur user UUID | -| `contextType` | string | Yes | Access context: TRAVELER or PROXY | +| `contextType` | string | Yes | Access context: TRAVELER or MANAGER | | `reportId` | string | Yes | Expense report ID | | `expenseId` | string | Yes | Expense ID | @@ -1478,8 +1466,27 @@ List allocations on an expense (GET /expensereports/v4/users/\{userId\}/context/ | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | json | Allocations list payload | -| ↳ `items` | array | Array of allocation objects \(allocationId, accountCode, percentage, allocationAmount, approvedAmount, claimedAmount, customData, expenseId, isSystemAllocation, isPercentEdited, overLimitAccountCode\) | +| `data` | array | Bare array of allocation objects \(ReportAllocationResponse\[\]\) | +| ↳ `allocationId` | string | Unique allocation identifier | +| ↳ `accountCode` | string | Ledger account code | +| ↳ `overLimitAccountCode` | string | Account code applied to amounts over the per-allocation limit | +| ↳ `percentage` | number | Allocation percentage | +| ↳ `allocationAmount` | json | Allocation amount \(value, currencyCode\) | +| ↳ `value` | number | Amount value | +| ↳ `currencyCode` | string | ISO 4217 currency code | +| ↳ `approvedAmount` | json | Pro-rated approved amount \(value, currencyCode\) | +| ↳ `value` | number | Amount value | +| ↳ `currencyCode` | string | ISO 4217 currency code | +| ↳ `claimedAmount` | json | Requested reimbursement amount \(value, currencyCode\) | +| ↳ `value` | number | Amount value | +| ↳ `currencyCode` | string | ISO 4217 currency code | +| ↳ `customData` | array | Custom field values \(id, value, isValid\) | +| ↳ `id` | string | Custom field identifier | +| ↳ `value` | string | Custom field value | +| ↳ `isValid` | boolean | Whether the value passes validation | +| ↳ `expenseId` | string | Associated expense identifier | +| ↳ `isSystemAllocation` | boolean | True when system-managed | +| ↳ `isPercentEdited` | boolean | True when the percentage was manually edited | ### SAP Concur List Attendee Associations @@ -1547,13 +1554,12 @@ List budget categories (GET /budget/v4/budgetCategory). | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | json | Budget categories collection payload | -| ↳ `items` | array | Array of budget category objects | -| ↳ `id` | string | Category ID | -| ↳ `name` | string | Admin-facing category name | -| ↳ `description` | string | Friendly name | -| ↳ `statusType` | string | Status: OPEN or REMOVED | -| ↳ `expenseTypes` | array | Expense types in this category \(id, featureTypeCode, expenseTypeCode, name\) | +| `data` | array | Top-level array of budget category objects | +| ↳ `id` | string | Category ID | +| ↳ `name` | string | Admin-facing category name | +| ↳ `description` | string | Friendly name | +| ↳ `statusType` | string | Status: OPEN or REMOVED | +| ↳ `expenseTypes` | array | Expense types in this category \(id, featureTypeCode, expenseTypeCode, name\) | ### SAP Concur List Budgets @@ -1572,7 +1578,7 @@ List budget item headers (GET /budget/v4/budgetItemHeader). | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `adminView` | boolean | No | When true, returns all budgets the caller can administer \(default false\) | | `offset` | number | No | Page offset \(Concur returns up to 50 budget headers per page\) | -| `responseSchema` | string | No | Response schema variant: "COMPACT" returns a smaller payload | +| `responseSchema` | string | No | Response schema variant: "COMPACT" returns a smaller payload. Defaults to the non-compact schema | #### Output @@ -1580,10 +1586,15 @@ List budget item headers (GET /budget/v4/budgetItemHeader). | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | | `data` | json | Budget headers collection payload | -| ↳ `items` | array | Array of budget item header summaries \(id, name, description, budgetItemStatusType, budgetType, currencyCode, fiscalYear, budgetAmounts, owner, ...\) | -| ↳ `offset` | number | Page offset | -| ↳ `limit` | number | Page size | -| ↳ `totalCount` | number | Total result count | +| ↳ `budgetItemHeaders` | array | Array of budget item header summaries \(id, name, description, budgetItemStatusType, budgetType, currencyCode, fiscalYear, budgetAmounts, owner, ...\) | +| ↳ `totalRows` | number | Total number of budget headers | +| ↳ `offset` | number | Offset of the current page | +| ↳ `limit` | number | Page size \(Concur returns up to 50\) | +| ↳ `href` | string | URL of the current page | +| ↳ `previous` | json | Previous page link \(\{ href \}\); null on the first page | +| ↳ `href` | string | Previous page URL | +| ↳ `next` | json | Next page link \(\{ href \}\); null when no results remain. This is the only forward cursor for paging | +| ↳ `href` | string | Next page URL | ### SAP Concur List Report Exceptions @@ -1603,6 +1614,7 @@ List exceptions on a report (GET /expensereports/v4/users/\{userId\}/context/\{c | `userId` | string | Yes | Concur user UUID | | `contextType` | string | Yes | Access context: TRAVELER, MANAGER, or PROXY | | `reportId` | string | Yes | Expense report ID | +| `excludeExpenses` | boolean | No | Return only exceptions for the report header, excluding expense-level and allocation-level exceptions \(default false\) | #### Output @@ -1641,7 +1653,22 @@ List expected expenses on a travel request (GET /travelrequest/v4/requests/\{req | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | json | Array of expected expense objects. Each entry includes id, href, expenseType \{id,name\}, transactionDate, transactionAmount, postedAmount, approvedAmount, remainingAmount, businessPurpose, location, exchangeRate, allocations, tripData, parentRequest \{href, id\}, comments \{href, id\}. | +| `data` | array | Array of expected expense objects | +| ↳ `id` | string | Expected expense identifier | +| ↳ `href` | string | Self-link | +| ↳ `expenseType` | json | Expense type \{id, name\} | +| ↳ `transactionDate` | string | Transaction date | +| ↳ `transactionAmount` | json | Transaction amount \{value, currency\} | +| ↳ `postedAmount` | json | Posted amount \{value, currency\} | +| ↳ `approvedAmount` | json | Approved amount \{value, currency\} | +| ↳ `remainingAmount` | json | Remaining amount on the expected expense | +| ↳ `businessPurpose` | string | Business purpose of the expense | +| ↳ `location` | json | Location \{id, name, city, countryCode, countrySubDivisionCode, iataCode, locationType\} | +| ↳ `exchangeRate` | json | Exchange rate \{value, operation\} | +| ↳ `allocations` | json | Budget allocations array | +| ↳ `tripData` | json | Trip data \{agencyBooked, selfBooked, tripType \(ONE_WAY\|ROUND_TRIP\), legs\[\{id, returnLeg, startDate, startTime, startLocationDetail, startLocation, endLocation, class \{code,value\}, travelExceptionReasonCodes\}\], segmentType \{category, code\}\} | +| ↳ `parentRequest` | json | Parent travel request resource link \{href, id\}. Documented on the single-expense GET, not on this list endpoint | +| ↳ `comments` | json | Comments sub-resource link \{href, id\}. Documented on the single-expense GET, not on this list endpoint | ### SAP Concur List Expenses @@ -1659,7 +1686,7 @@ List expenses on a report (GET /expensereports/v4/users/\{userId\}/context/\{con | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userId` | string | Yes | Concur user UUID | -| `contextType` | string | Yes | Access context: TRAVELER, MANAGER, or PROXY | +| `contextType` | string | Yes | Access context: TRAVELER \(the only value the endpoint supports\) | | `reportId` | string | Yes | Expense report ID | #### Output @@ -1695,6 +1722,8 @@ List expenses on a report (GET /expensereports/v4/users/\{userId\}/context/\{con | ↳ `ereceiptImageId` | string | eReceipt image identifier | | ↳ `ticketNumber` | string | Ticket number | | ↳ `exchangeRate` | json | Exchange rate | +| ↳ `fuelTypeListItem` | json | Fuel type list item \{id, value, isValid\} | +| ↳ `jptRouteId` | string | Japan Public Transport route id | | ↳ `travelAllowance` | json | Travel allowance | | ↳ `expenseSourceIdentifiers` | json | Expense source identifiers | | ↳ `links` | array | HATEOAS links | @@ -1707,7 +1736,7 @@ List expense reports (GET /api/v3.0/expense/reports). Returns a v3 envelope with | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `datacenter` | string | No | Concur datacenter base URL \(us, us2, eu, eu2, cn, emea — defaults to us.api.concursolutions.com\) | +| `datacenter` | string | No | Concur datacenter base URL \(defaults to us.api.concursolutions.com\) | | `grantType` | string | No | OAuth grant type: client_credentials \(default\) or password | | `clientId` | string | Yes | Concur OAuth client ID | | `clientSecret` | string | Yes | Concur OAuth client secret | @@ -1727,8 +1756,8 @@ List expense reports (GET /api/v3.0/expense/reports). Returns a v3 envelope with | `paymentStatusCode` | string | No | Filter by payment status code | | `currencyCode` | string | No | Filter by ISO currency code \(e.g. USD, EUR\) | | `approverLoginID` | string | No | Filter by approver login ID | -| `limit` | number | No | Number of records per page \(default 25, max 100\) | -| `offset` | string | No | Opaque cursor token returned by a prior call \(NextPage\). | +| `limit` | number | No | Number of records per page \(default 25\) | +| `offset` | string | No | Pagination token. The previous response returns NextPage as a full URI — extract its `offset` query parameter and pass that value here, not the whole URI. | #### Output @@ -1759,7 +1788,7 @@ List expense reports (GET /api/v3.0/expense/reports). Returns a v3 envelope with | ↳ `LastModifiedDate` | string | Last modified date | | ↳ `PaidDate` | string | Paid date | | ↳ `URI` | string | Self URI | -| ↳ `NextPage` | string | URI of the next page \(use as offset cursor\) | +| ↳ `NextPage` | string | Full URI of the next page — read its `offset` query parameter to page forward | ### SAP Concur List Trips @@ -1778,50 +1807,25 @@ List travel trips/itineraries (GET /api/travel/trip/v1.1). | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `startDate` | string | No | Filter trips starting on/after this date \(YYYY-MM-DD\) | | `endDate` | string | No | Filter trips ending on/before this date \(YYYY-MM-DD\) | -| `bookingType` | string | No | Filter by booking type \(air, car, hotel, rail, etc.\) | -| `useridType` | string | No | User identifier type \(login, xmlsyncid, uuid\) | +| `bookingType` | string | No | Filter by booking type. Supported values are capitalized: Air, Car, Dining, Hotel, Parking, Rail, Ride. | +| `useridType` | string | No | User identifier type. The only value documented for Trips v1.1 is "login" \(the value is the user login id\); xmlsyncid and uuid are Travel Profile v2 identifier types and are not documented for this endpoint. | | `useridValue` | string | No | User identifier value \(paired with useridType\) | -| `itemsPerPage` | number | No | Items per page | -| `page` | number | No | 1-based page number | -| `includeMetadata` | boolean | No | Include paging metadata in the response | +| `itemsPerPage` | number | No | Items per page. Concur only paginates when includeMetadata is also sent, so this tool sets includeMetadata automatically whenever itemsPerPage or page is provided. | +| `page` | number | No | 1-based page number. Concur only paginates when includeMetadata is also sent, so this tool sets includeMetadata automatically whenever page or itemsPerPage is provided. | +| `includeMetadata` | boolean | No | Include paging metadata in the response. Implied when page or itemsPerPage is set. | | `includeCanceledTrips` | boolean | No | Include canceled trips in the result set | | `createdAfterDate` | string | No | Only trips created after this date \(YYYY-MM-DD\) | | `createdBeforeDate` | string | No | Only trips created before this date \(YYYY-MM-DD\) | | `lastModifiedDate` | string | No | Only trips modified on/after this date \(YYYY-MM-DD\) | +| `includeVirtualTrip` | string | No | Set to "1" to include virtual trips, which carry the offline segments booked through Concur Request. | +| `includeGuestBookings` | boolean | No | Include trips booked on behalf of guests. Defaults to false. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | json | Trips list payload \(Itinerary v1.1 ConnectResponse\) | -| ↳ `Metadata` | json | Paging metadata \(when includeMetadata=true\) | -| ↳ `Paging` | json | Pagination details | -| ↳ `TotalPages` | number | Total pages | -| ↳ `TotalItems` | number | Total items | -| ↳ `Page` | number | Current page | -| ↳ `ItemsPerPage` | number | Items per page | -| ↳ `PreviousPageURL` | string | Previous page URL | -| ↳ `NextPageURL` | string | Next page URL | -| ↳ `ItineraryInfoList` | array | List of itinerary summary records | -| ↳ `ItinLocator` | string | Trip locator \(trip ID\) | -| ↳ `ClientLocator` | string | Client trip locator | -| ↳ `ItinSourceName` | string | Booking source name | -| ↳ `BookedVia` | string | Booking channel | -| ↳ `TripName` | string | Trip name | -| ↳ `Status` | string | Trip status | -| ↳ `Description` | string | Trip description | -| ↳ `StartDateUtc` | string | Start \(UTC\) | -| ↳ `EndDateUtc` | string | End \(UTC\) | -| ↳ `StartDateLocal` | string | Start \(local\) | -| ↳ `EndDateLocal` | string | End \(local\) | -| ↳ `DateCreatedUtc` | string | Created \(UTC\) | -| ↳ `DateModifiedUtc` | string | Modified \(UTC\) | -| ↳ `DateBookedLocal` | string | Booked \(local\) | -| ↳ `UserLoginId` | string | Trip owner login id | -| ↳ `BookedByFirstName` | string | Booker first name | -| ↳ `BookedByLastName` | string | Booker last name | -| ↳ `IsPersonal` | boolean | Personal trip flag | +| `data` | string | Raw XML trips list returned by Concur \(Trips v1.1 emits application/xml only, so this is a string and not a parsed object\). By default the document is rooted at <ItineraryInfoList> containing one <ItineraryInfo> per trip \(TripId, TripName, TripStatus, StartDateLocal, EndDateLocal, DateModifiedUtc, UserLoginId, id\). When includeMetadata is sent — which this tool does automatically whenever page or itemsPerPage is supplied — the document is instead rooted at <ConnectResponse> with ConnectResponse > Metadata > Paging \(TotalPages, TotalItems, Page, ItemsPerPage, PreviousPageURL, NextPageURL\) and ConnectResponse > Data > ItineraryInfoList > ItineraryInfo. | ### SAP Concur List Lists @@ -1841,10 +1845,10 @@ List custom lists (GET /list/v4/lists). | `page` | number | No | Page number \(1-based; page size is fixed at 100\) | | `sortBy` | string | No | Sort field: name, levelcount, or listcategory | | `sortDirection` | string | No | Sort direction: asc or desc | -| `value` | string | No | Filter by list name | -| `categoryType` | string | No | Filter by category type \(mapped to category.type query param\) | -| `isDeleted` | boolean | No | Include deleted lists | -| `levelCount` | number | No | Filter by number of levels | +| `value` | string | No | Filter by list name. Accepts an operator prefix: sw: \(starts with\), ew: \(ends with\), not:, cp: \(contains\) \(e.g. "sw:Cost"\). | +| `categoryType` | string | No | Filter by category type \(mapped to the category.type query param\). Accepts an operator prefix: eq:, not:. | +| `isDeleted` | string | No | Filter by deletion status. Pass "true" or "false" as a string because the filter also accepts the eq operator prefix \(eq:true\) — eq is the only operator this filter supports. | +| `levelCount` | string | No | Filter by number of levels. Accepts an operator prefix: eq:, gt:, gte:, lt:, lte: \(e.g. "eq:1", "gt:2", "lte:9"\). | #### Output @@ -1894,7 +1898,7 @@ List the top-level items (children) for a custom list (GET /list/v4/lists/\{list | `sortBy` | string | No | Sort field: value or shortCode | | `sortDirection` | string | No | Sort direction: asc or desc | | `hasChildren` | boolean | No | Include only items that have children | -| `isDeleted` | boolean | No | Include deleted items | +| `isDeleted` | string | No | Filter by deletion status. Pass "true" or "false" as a string because the filter also accepts the eq operator prefix \(eq:true\) — eq is the only operator this filter supports. | | `shortCode` | string | No | Filter by short code | | `value` | string | No | Filter by display value | | `shortCodeOrValue` | string | No | Filter by short code OR value | @@ -1907,6 +1911,7 @@ List the top-level items (children) for a custom list (GET /list/v4/lists/\{list | `data` | json | Paginated list items collection | | ↳ `content` | array | List items in the current page | | ↳ `id` | string | List item UUID | +| ↳ `listId` | string | UUID of the list that contains the list item | | ↳ `code` | string | Long code format for the item | | ↳ `shortCode` | string | Short code identifier | | ↳ `value` | string | Display value of the item | @@ -1927,7 +1932,7 @@ List the top-level items (children) for a custom list (GET /list/v4/lists/\{list ### SAP Concur List Receipts -List receipts for a user (GET /receipts/v4/users/\{userId\}). +List receipts for a user (GET /receipts/v4/users/\{userId\}). Concur documents no query parameters for this endpoint, so page size and offset cannot be controlled; follow the "next" URL in the response to page forward. #### Input @@ -1947,15 +1952,17 @@ List receipts for a user (GET /receipts/v4/users/\{userId\}). | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | array | Array of e-receipt objects | -| ↳ `id` | string | Receipt id | -| ↳ `userId` | string | Owner user UUID | -| ↳ `dateTimeReceived` | string | Timestamp the receipt was received | -| ↳ `receipt` | json | Structured receipt data | -| ↳ `image` | string | Receipt image URL or reference | -| ↳ `validationSchema` | string | Validation schema URI | -| ↳ `self` | string | Self URL | -| ↳ `template` | string | Template URL | +| `data` | json | E-receipt collection wrapper | +| ↳ `receipts` | array | Array of e-receipt objects | +| ↳ `id` | string | Receipt id | +| ↳ `userId` | string | Owner user UUID | +| ↳ `dateTimeReceived` | string | Timestamp the receipt was received | +| ↳ `receipt` | json | Structured receipt data | +| ↳ `image` | string | Receipt image URL or reference | +| ↳ `validationSchema` | string | Validation schema URI | +| ↳ `self` | string | Self URL | +| ↳ `template` | string | Template URL | +| ↳ `next` | string | URL of the next page of receipts, if returned. Concur documents this cursor on the image-only-receipts endpoint rather than on this one | ### SAP Concur List Report Comments @@ -1973,7 +1980,7 @@ List comments on a report (GET /expensereports/v4/users/\{userId\}/context/\{con | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userId` | string | Yes | Concur user UUID | -| `contextType` | string | Yes | Access context: TRAVELER or PROXY | +| `contextType` | string | Yes | Access context: TRAVELER, MANAGER, or PROXY | | `reportId` | string | Yes | Expense report ID | | `includeAllComments` | boolean | No | Include comments from all expenses in the report \(default false\) | @@ -1985,7 +1992,7 @@ List comments on a report (GET /expensereports/v4/users/\{userId\}/context/\{con | `data` | array | Array of report comment entries | | ↳ `comment` | string | Comment text | | ↳ `creationDate` | string | Comment creation timestamp \(ISO 8601\) | -| ↳ `expenseId` | string | Related expense entry ID | +| ↳ `expenseId` | string | Related expense entry ID \(null for report header comments\) | | ↳ `isAuditorComment` | boolean | Whether the comment was added by an auditor | | ↳ `isLatest` | boolean | Whether this is the latest comment | | ↳ `createdForEmployeeId` | string | Employee ID the comment was created for | @@ -2040,7 +2047,7 @@ List expense reports awaiting approval (GET /expensereports/v4/users/\{userId\}/ ### SAP Concur Get Request Cash Advance -Get a single cash advance assigned to a travel request (GET /travelrequest/v4/cashadvances/\{cashAdvanceUuid\}). +Get a single cash advance assigned to a travel request (GET /travelrequest/v4/cashadvances/\{cashAdvanceUuid\}). This endpoint exists for feature parity only and will be deprecated in the future — SAP recommends relying on the list of cash advances link available in the Request payload response instead. #### Input @@ -2063,13 +2070,15 @@ Get a single cash advance assigned to a travel request (GET /travelrequest/v4/ca | `data` | json | Cash advance detail | | ↳ `cashAdvanceId` | string | Unique cash advance identifier | | ↳ `amountRequested` | json | Requested amount | -| ↳ `value` | number | Amount value | +| ↳ `amount` | number | Preferred amount field — use this over value | +| ↳ `value` | number | Legacy amount value — will soon be deprecated in favor of amount | | ↳ `currency` | string | Currency code | -| ↳ `amount` | number | Amount \(alias\) | | ↳ `approvalStatus` | json | Approval status | | ↳ `code` | string | Status code | | ↳ `name` | string | Status name | | ↳ `requestDate` | string | Request datetime \(ISO 8601\) | +| ↳ `issueDate` | string | Date the cash advance was issued \(ISO 8601\) | +| ↳ `comment` | string | Comment attached to the cash advance | | ↳ `exchangeRate` | json | Exchange rate | | ↳ `value` | number | Rate value | | ↳ `operation` | string | Multiply or divide | @@ -2093,31 +2102,14 @@ List travel profile summaries (GET /api/travelprofile/v2.0/summary). LastModifie | `page` | number | No | 1-based page number | | `itemsPerPage` | number | No | Items per page \(max 200\) | | `travelConfigs` | string | No | Comma-separated travel configuration ids | +| `active` | string | No | Filter by user state: "1" returns active users, "0" returns inactive users. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | json | Travel profile summary list payload \(Concur returns XML mapped to JSON\) | -| ↳ `Metadata` | json | Paging metadata | -| ↳ `Paging` | json | Pagination details | -| ↳ `TotalPages` | number | Total number of pages | -| ↳ `TotalItems` | number | Total number of items | -| ↳ `Page` | number | Current page | -| ↳ `ItemsPerPage` | number | Items per page | -| ↳ `PreviousPageURL` | string | URL to the previous page | -| ↳ `NextPageURL` | string | URL to the next page | -| ↳ `Data` | array | Array of travel profile summaries | -| ↳ `Status` | string | Status \(Active/Inactive\) | -| ↳ `LoginID` | string | Login identifier | -| ↳ `XmlProfileSyncID` | string | XML profile sync identifier | -| ↳ `ProfileLastModifiedUTC` | string | Last modified timestamp \(UTC\) | -| ↳ `RuleClass` | string | Travel rule class assigned to the profile | -| ↳ `TravelConfigID` | string | Travel configuration identifier | -| ↳ `UUID` | string | Profile UUID | -| ↳ `EmployeeID` | string | Employee ID | -| ↳ `CompanyID` | string | Company ID | +| `data` | string | Raw XML travel profile summary list returned by Concur \(Travel Profile v2 emits application/xml only, per the TravelProfileSummaryV2.xsd schema, so this is a string and not a parsed object\). The document is rooted at <ConnectResponse> with ConnectResponse > Metadata > Paging \(TotalPages, TotalItems, Page, ItemsPerPage, PreviousPageURL, NextPageURL\) and ConnectResponse > Data > ProfileSummary, whose only child elements are Status, LoginID, XmlProfileSyncID, and ProfileLastModifiedUTC. | ### SAP Concur List Travel Request Comments @@ -2164,10 +2156,10 @@ List travel requests (GET /travelrequest/v4/requests). | `username` | string | No | Username \(only for password grant\) | | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | -| `view` | string | No | View filter \(e.g., ALL, ACTIVE, PENDING, TOAPPROVE\) | -| `limit` | number | No | Max number of results per page | +| `view` | string | No | View filter: ALL, ACTIVE, ACTIVEAPPROVED, UNSUBMITTED, PENDING, VALIDATED, APPROVED, CANCELED, CLOSED, SUBMITTED, TOAPPROVE, PENDINGEBOOKING, PENDINGPROPOSAL, PROPOSALAPPROVED, or PROPOSALCANCELED. Defaults to ALL when omitted. The three TMC-agent views \(PENDINGPROPOSAL, PROPOSALAPPROVED, PROPOSALCANCELED\) require userId. | +| `limit` | number | No | Records per page \(default 10, maximum 100 — higher values return 400\) | | `start` | number | No | Page start cursor \(offset\) | -| `userId` | string | No | Filter by Concur user UUID | +| `userId` | string | No | For a traveler view, the unique identifier of the Request owner to search for. For an approver view, the unique identifier of the approver. For a TMC-agent view \(PENDINGPROPOSAL, PROPOSALAPPROVED, PROPOSALCANCELED\) this is required and is the unique identifier of the TMC agent. | | `approvedBefore` | string | No | ISO 8601 date — return requests approved before this date | | `approvedAfter` | string | No | ISO 8601 date — return requests approved after this date | | `modifiedBefore` | string | No | ISO 8601 date — return requests modified before this date | @@ -2225,8 +2217,6 @@ List travel requests (GET /travelrequest/v4/requests). | ↳ `operations` | array | Pagination links \(next, prev, first, last\) | | ↳ `rel` | string | Link relation | | ↳ `href` | string | Link target | -| ↳ `method` | string | HTTP method | -| ↳ `name` | string | Link name | ### SAP Concur List Users @@ -2244,7 +2234,7 @@ List Concur user identities (GET /profile/identity/v4.1/Users). | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `count` | number | No | Max number of users to return \(default 100, max 1000\) | -| `cursor` | string | No | SCIM v4.1 pagination cursor returned by a prior call | +| `cursor` | string | No | SCIM v4.1 pagination cursor — the nextCursor value returned by a prior call | | `attributes` | string | No | Comma-separated list of attributes to include in the response | | `excludedAttributes` | string | No | Comma-separated list of attributes to exclude from the response | @@ -2272,40 +2262,104 @@ Move a travel request through workflow (POST /travelrequest/v4/requests/\{reques | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `requestUuid` | string | Yes | Travel request UUID | | `action` | string | Yes | Workflow action: submit, recall, cancel, approve, sendback, close, reopen | -| `userId` | string | No | Optional Concur user UUID — required when impersonating another user | -| `body` | json | No | Optional payload \(e.g., \{ "comment": "..." \}\) | +| `userId` | string | No | The unique identifier of the user performing the status transition. Required when connecting with a Company token for traveler and Non traveler actions only; not required for External system validation actions. If empty, a 400 `missingRequiredParam` error code is returned. For non-traveler actions, if not provided, "System, Concur" is displayed in the Audit Trail of the Request. | +| `companyID` | string | No | Optional company identifier for the workflow action \(documented as `companyID`, distinct from the companyUuid auth field\) | +| `comment` | string | No | Comment sent as a query parameter. Only works when the workflow action is `sendback`. This comment is visible wherever Request comments are available. | +| `body` | json | No | Optional payload — only the `sendback` action accepts one \(e.g., \{ "comment": "..." \}\). Every other action takes no payload. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `status` | number | HTTP status code returned by Concur | -| `data` | json | Workflow transition response payload | +| `data` | json | The full travel request having that requestUuid, after the workflow transition | | ↳ `id` | string | Travel request UUID | | ↳ `href` | string | Resource hyperlink | +| ↳ `requestId` | string | Public-facing request ID \(4-6 alphanumeric characters\) | +| ↳ `name` | string | Request name | +| ↳ `businessPurpose` | string | Business purpose | +| ↳ `comment` | string | Last attached comment | +| ↳ `creationDate` | string | Creation timestamp | +| ↳ `lastModified` | string | Last modification timestamp | +| ↳ `submitDate` | string | Last submission timestamp | +| ↳ `authorizedDate` | string | Date when approval was completed | +| ↳ `approvalLimitDate` | string | Required approval deadline | +| ↳ `startDate` | string | Trip start date \(ISO 8601\) | +| ↳ `endDate` | string | Trip end date \(ISO 8601\) | +| ↳ `startTime` | string | Trip start time \(HH:mm\) | +| ↳ `endTime` | string | Trip end time \(HH:mm\) | +| ↳ `approved` | boolean | Whether the request is approved | +| ↳ `pendingApproval` | boolean | Pending approval flag | +| ↳ `closed` | boolean | Closed flag | +| ↳ `everSentBack` | boolean | Ever-sent-back flag | +| ↳ `canceledPostApproval` | boolean | Canceled after approval flag | +| ↳ `highestExceptionLevel` | string | Highest exception level \(WARNING, ERROR, NONE\) | | ↳ `approvalStatus` | json | Approval status after the workflow transition | | ↳ `code` | string | Status code \(NOT_SUBMITTED, SUBMITTED, APPROVED, CANCELED, SENTBACK\) | | ↳ `name` | string | Localized status name | +| ↳ `owner` | json | Travel request owner | +| ↳ `id` | string | User UUID | +| ↳ `firstName` | string | Owner first name | +| ↳ `lastName` | string | Owner last name | | ↳ `approver` | json | Approver assigned after the transition | | ↳ `id` | string | User UUID | | ↳ `firstName` | string | Approver first name | | ↳ `lastName` | string | Approver last name | +| ↳ `policy` | json | Resource link to the applicable policy | +| ↳ `id` | string | Policy ID | +| ↳ `href` | string | Policy hyperlink | +| ↳ `type` | json | Request type | +| ↳ `code` | string | Request type code | +| ↳ `label` | string | Request type label | +| ↳ `mainDestination` | json | Main destination of the trip | +| ↳ `city` | string | City | +| ↳ `countryCode` | string | ISO country code | +| ↳ `countrySubDivisionCode` | string | ISO country sub-division code | +| ↳ `name` | string | Destination name | +| ↳ `totalApprovedAmount` | json | Total approved amount | +| ↳ `value` | number | Amount value | +| ↳ `currency` | string | Currency code | +| ↳ `totalPostedAmount` | json | Total posted amount | +| ↳ `value` | number | Amount value | +| ↳ `currency` | string | Currency code | +| ↳ `totalRemainingAmount` | json | Total remaining amount | +| ↳ `value` | number | Amount value | +| ↳ `currency` | string | Currency code | +| ↳ `expenses` | array | Resource links to expected expenses | +| ↳ `cashAdvances` | json | Resource link to cash advances | +| ↳ `id` | string | Resource ID | +| ↳ `href` | string | Resource hyperlink | +| ↳ `comments` | json | Resource link to comments | +| ↳ `id` | string | Resource ID | +| ↳ `href` | string | Resource hyperlink | +| ↳ `exceptions` | json | Resource link to exceptions | +| ↳ `id` | string | Resource ID | +| ↳ `href` | string | Resource hyperlink | +| ↳ `travelAgency` | json | Resource link to travel agency | +| ↳ `id` | string | Resource ID | +| ↳ `href` | string | Resource hyperlink | +| ↳ `extensionOf` | json | The Request for which this Request is an extension of, or addendum to | +| ↳ `requestId` | string | The public key of the Request \(unique per customer\) | +| ↳ `id` | string | Unique identifier of the Request | +| ↳ `href` | string | Hyperlink to the resource | +| ↳ `template` | string | Hyperlink template to the resource | +| ↳ `expensePolicy` | json | Expense policy reference | +| ↳ `id` | string | Policy identifier | +| ↳ `href` | string | Policy URL | | ↳ `operations` | array | Available follow-up workflow actions | | ↳ `rel` | string | Link relation | | ↳ `href` | string | Link target | -| ↳ `method` | string | HTTP method | -| ↳ `name` | string | Link name | ### SAP Concur Recall Expense Report -Recall a submitted expense report (PATCH /expensereports/v4/users/\{userId\}/context/\{contextType\}/reports/\{reportId\}/recall — supported contexts: TRAVELER, PROXY). No request body is required. +Recall a submitted expense report (PATCH /expensereports/v4/users/\{userId\}/context/\{contextType\}/reports/\{reportId\}/recall — supported contexts: TRAVELER, PROXY). Takes no request body. This operation supports user-level access tokens: set grantType to "password" with username and password, since the default client_credentials grant yields a company-level token. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `datacenter` | string | No | Concur datacenter base URL \(defaults to us.api.concursolutions.com\) | -| `grantType` | string | No | OAuth grant type: client_credentials \(default\) or password | +| `grantType` | string | No | OAuth grant type: client_credentials \(default\) or password. Recall requires a user-level access token, so set this to "password" and supply username/password — client_credentials produces a company-level token that Concur rejects for this operation. | | `clientId` | string | Yes | Concur OAuth client ID | | `clientSecret` | string | Yes | Concur OAuth client secret | | `username` | string | No | Username \(only for password grant\) | @@ -2314,7 +2368,6 @@ Recall a submitted expense report (PATCH /expensereports/v4/users/\{userId\}/con | `userId` | string | Yes | Concur user UUID who owns the report | | `contextType` | string | Yes | Access context: TRAVELER or PROXY | | `reportId` | string | Yes | Expense report ID to recall | -| `body` | json | No | Optional body. Concur docs don't define a payload for this action; pass an empty object if uncertain. | #### Output @@ -2365,13 +2418,13 @@ Search Concur location reference data (GET /localities/v5/locations). | `username` | string | No | Username \(only for password grant\) | | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | -| `searchText` | string | No | Free-text query \(city, airport, landmark, etc.\) | -| `locCode` | string | No | IATA / location code | -| `locationNameId` | string | No | Concur internal location name ID \(UUID\) | -| `locationNameKey` | number | No | Concur internal numeric location name key | -| `countryCode` | string | No | 2-letter ISO 3166-1 country code | -| `subdivisionCode` | string | No | ISO 3166-2:2007 country subdivision \(e.g. US-WA\) | -| `adminRegionId` | string | No | Administrative region ID | +| `searchText` | string | No | Free-text for location search. Conditional — required if none of locationNameKey, locationNameId, or locCode is present. | +| `locCode` | string | No | Location code. Conditional — required if none of locationNameKey, locationNameId, or searchText is present. | +| `locationNameId` | string | No | UUID identifier of the location name. Conditional — required if none of locationNameKey, locCode, or searchText is present. | +| `locationNameKey` | number | No | Unique key for the location name. Conditional — required if none of locationNameId, locCode, or searchText is present. | +| `countryCode` | string | No | 2-letter ISO 3166-1 country code. Only valid together with searchText. | +| `subdivisionCode` | string | No | ISO 3166-2:2007 country subdivision \(e.g. US-WA\). Only valid together with searchText. | +| `adminRegionId` | string | No | Administrative region ID. Only valid together with searchText. | #### Output @@ -2383,27 +2436,31 @@ Search Concur location reference data (GET /localities/v5/locations). | ↳ `id` | string | Location ID \(UUID\) | | ↳ `code` | string | IATA / location code | | ↳ `legacyKey` | number | Legacy numeric location key | -| ↳ `timeZoneOffset` | string | IANA timezone or UTC offset | +| ↳ `timeZoneOffset` | number | Time zone offset of the location, in minutes \(e.g. 60\) | | ↳ `active` | boolean | Whether the location is active | | ↳ `point` | json | Geographic coordinates | | ↳ `latitude` | number | Latitude | | ↳ `longitude` | number | Longitude | | ↳ `names` | array | Localized location names | | ↳ `id` | string | Name ID | -| ↳ `key` | number | Numeric name key | -| ↳ `locale` | string | Locale tag | +| ↳ `legacyKey` | number | Legacy numeric name key | +| ↳ `langCode` | string | Language code | | ↳ `name` | string | Display name | +| ↳ `active` | boolean | Whether the name is active | | ↳ `administrativeRegion` | json | Administrative region \(e.g., metro area\) | -| ↳ `id` | string | Region ID | -| ↳ `name` | string | Region name | -| ↳ `country` | json | Country reference | -| ↳ `id` | string | Country ID | +| ↳ `id` | string | Unique identifier of the admin region | +| ↳ `names` | array | Localized region names | +| ↳ `countryCode` | string | ISO 3166-1 country code of the region | +| ↳ `subDivCode` | string | ISO 3166-2 subdivision code of the region | +| ↳ `links` | array | HATEOAS links | +| ↳ `country` | json | Country reference \(Code schema\) | | ↳ `code` | string | ISO country code | -| ↳ `name` | string | Country name | -| ↳ `subDivision` | json | Country subdivision \(state/province\) | -| ↳ `id` | string | Subdivision ID | +| ↳ `names` | array | Localized country names | +| ↳ `links` | array | HATEOAS links | +| ↳ `subDivision` | json | Country subdivision \(state/province, Code schema\) | | ↳ `code` | string | ISO subdivision code | -| ↳ `name` | string | Subdivision name | +| ↳ `names` | array | Localized subdivision names | +| ↳ `links` | array | HATEOAS links | | ↳ `links` | array | HATEOAS links | ### SAP Concur Search Users @@ -2421,7 +2478,7 @@ Search users via SCIM .search endpoint (POST /profile/identity/v4.1/Users/.searc | `username` | string | No | Username \(only for password grant\) | | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | -| `body` | json | Yes | SCIM search request payload \(\{ schemas, attributes, filter, count, startIndex \}\) | +| `body` | json | Yes | SCIM search payload. Required: schemas: \["urn:ietf:params:scim:api:messages:concur:2.0:SearchRequest"\] \(Concur-specific URN, not the standard SearchRequest URN\). Optional: filter, count \(1-1000\), attributes, excludedAttributes, cursor \(the nextCursor value from a prior response\). The startIndex request parameter is not supported \(responses still return a startIndex value\). | #### Output @@ -2457,7 +2514,7 @@ Send back an expense report to the employee (PATCH /expensereports/v4/reports/\{ ### SAP Concur Submit Expense Report -Submit an expense report into the workflow via Expense Report v4 (PATCH /expensereports/v4/users/\{userId\}/reports/\{reportId\}/submit). +Submit an expense report into the workflow via Expense Report v4 (PATCH /expensereports/v4/users/\{userId\}/reports/\{reportId\}/submit). Takes no request body. #### Input @@ -2472,7 +2529,6 @@ Submit an expense report into the workflow via Expense Report v4 (PATCH /expense | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userId` | string | Yes | Concur user UUID who owns the report | | `reportId` | string | Yes | Expense report ID to submit | -| `body` | json | No | Optional body. Concur docs don't define a payload for this action; pass an empty object if uncertain. | #### Output @@ -2500,7 +2556,7 @@ Update an allocation (PATCH /expensereports/v4/users/\{userId\}/context/\{contex | `contextType` | string | Yes | Access context: TRAVELER or PROXY \(write requires expense.report.readwrite\) | | `reportId` | string | Yes | Expense report ID | | `allocationId` | string | Yes | Allocation ID to update | -| `body` | json | Yes | Fields to update on the allocation | +| `body` | json | Yes | JSON Merge Patch \(RFC 7386\) payload. Must be the two-key envelope \{ "allocation": \{ "customData": \[\{ "id": "custom9", "value": "...", "isValid": true \}\] \}, "expenseIds": \["29EE..."\] \}. | #### Output @@ -2538,9 +2594,9 @@ Update an expected expense (PUT /travelrequest/v4/expenses/\{expenseUuid\}). | ↳ `href` | string | Self-link | | ↳ `expenseType` | json | Expense type \{id, name\} | | ↳ `transactionDate` | string | Transaction date | -| ↳ `transactionAmount` | json | Transaction amount \{value, currencyCode\} | -| ↳ `postedAmount` | json | Posted amount \{value, currencyCode\} | -| ↳ `approvedAmount` | json | Approved amount \{value, currencyCode\} | +| ↳ `transactionAmount` | json | Transaction amount \{value, currency\} | +| ↳ `postedAmount` | json | Posted amount \{value, currency\} | +| ↳ `approvedAmount` | json | Approved amount \{value, currency\} | | ↳ `remainingAmount` | json | Remaining amount on the expected expense | | ↳ `businessPurpose` | string | Business purpose of the expense | | ↳ `location` | json | Location \{id, name, city, countryCode, countrySubDivisionCode, iataCode, locationType\} | @@ -2552,7 +2608,7 @@ Update an expected expense (PUT /travelrequest/v4/expenses/\{expenseUuid\}). ### SAP Concur Update Expense -Update an expense (PATCH /expensereports/v4/reports/\{reportId\}/expenses/\{expenseId\}). +Update an expense (PATCH /expensereports/v4/reports/\{reportId\}/expenses/\{expenseId\}). Only Company JWT authentication is allowed on this endpoint — the password grant is rejected. A submitted report cannot be updated once it has reached a Paid workflow status. Although the primary intent of this operation is for submitted report updates, it also works on unsubmitted reports, but with the same limited set of fields. #### Input @@ -2578,7 +2634,7 @@ Update an expense (PATCH /expensereports/v4/reports/\{reportId\}/expenses/\{expe ### SAP Concur Update Expense Report -Update an unsubmitted expense report (PATCH /expensereports/v4/users/\{userId\}/context/\{contextType\}/reports/\{reportId\} — supported contexts: TRAVELER, PROXY). Body fields: businessPurpose, comment, customData, name, etc. +Update an unsubmitted expense report (PATCH /expensereports/v4/users/\{userId\}/context/\{contextType\}/reports/\{reportId\} — supported contexts: TRAVELER, PROXY). The body must always include `reportSource` (EA, MOB, OTHER, SE, TR, or UI). #### Input @@ -2594,7 +2650,7 @@ Update an unsubmitted expense report (PATCH /expensereports/v4/users/\{userId\}/ | `userId` | string | Yes | Concur user UUID who owns the report | | `contextType` | string | Yes | Access context: TRAVELER \(own report\) or PROXY \(editing on behalf of another user\) | | `reportId` | string | Yes | Expense report ID to update | -| `body` | json | Yes | Fields to update on the report | +| `body` | json | Yes | Fields to update on the report. `reportSource` is REQUIRED by Concur on every update — one of "EA", "MOB", "OTHER", "SE", "TR", "UI" \(use "OTHER" if unknown\). Other updatable fields: businessPurpose, comment, country, countryCode, countrySubDivisionCode, customData, endDate, isCopyDownInherited, isPaperReceiptsReceived, name, policy, policyId, redirectFund, reportDate, startDate. | #### Output @@ -2628,6 +2684,7 @@ Update a list item (PUT /list/v4/items/\{itemId\}). | `status` | number | HTTP status code returned by Concur | | `data` | json | Updated list item | | ↳ `id` | string | List item UUID | +| ↳ `listId` | string | UUID of the list that contains the list item | | ↳ `code` | string | Long code format for the item | | ↳ `shortCode` | string | Short code identifier | | ↳ `value` | string | Display value of the item | @@ -2654,7 +2711,8 @@ Update a travel request (PUT /travelrequest/v4/requests/\{requestUuid\}). | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `requestUuid` | string | Yes | Travel request UUID to update | -| `body` | json | Yes | Fields to update on the travel request | +| `userId` | string | No | The unique identifier of the user performing the update. Optional. Will be taken into account only if calling with a Company token. If not provided the update will be performed as "Concur System". | +| `body` | json | Yes | Fields to update on the travel request. Partial update is supported. Only these fields are updatable: comment, startDate, startTime, endDate, endTime, expensePolicy, name, businessPurpose, mainDestination, travelAgency, and the custom1-custom20 fields — any other field is silently ignored. Pass an unquoted null to clear a field. | #### Output @@ -2729,7 +2787,7 @@ Patch a user identity (PATCH /profile/identity/v4.1/Users/\{id\}). | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userUuid` | string | Yes | User UUID to update | -| `body` | json | Yes | SCIM PATCH operations payload \(\{ schemas, Operations: \[...\] \}\) | +| `body` | json | Yes | SCIM PATCH payload. Required: schemas: \["urn:ietf:params:scim:api:messages:2.0:PatchOp"\] and Operations, an array of \{ op, path, value \} where op is add, replace, or remove. If the target location is a multi-valued attribute and no filter is specified, the attribute and all values are replaced. Example: deactivate a user with \{ op: "replace", path: "active", value: false \}. | #### Output @@ -2754,8 +2812,7 @@ Upload an image-only receipt (POST /receipts/v4/users/\{userId\}/image-only-rece | `password` | string | No | Password \(only for password grant\) | | `companyUuid` | string | No | Company UUID for multi-company access tokens | | `userId` | string | Yes | Concur user UUID who owns the receipt | -| `receipt` | json | Yes | Receipt image file \(UserFile reference\). Supported formats: PDF, PNG, JPEG, GIF, TIFF | -| `forwardId` | string | No | Optional client-supplied dedup id \(max 40 chars\). Sent as the concur-forwardid header. | +| `receipt` | json | Yes | Receipt image file \(UserFile reference\). Supported formats: png, jpg, jpeg, tiff, tif, gif, pdf. TIFF/TIF files are converted to PDF server-side. Maximum size 25 MB. | #### Output @@ -2764,6 +2821,6 @@ Upload an image-only receipt (POST /receipts/v4/users/\{userId\}/image-only-rece | `status` | number | HTTP status code returned by Concur | | `data` | json | Image-only receipt upload response \(HTTP 202 Accepted; Location and Link response headers exposed in body\) | | ↳ `location` | string | Location header URL for the new receipt image \(e.g. /receipts/v4/images/\{receiptId\}\) | -| ↳ `link` | string | Link header URL pointing to /receipts/v4/status/\{receiptId\} | +| ↳ `link` | string | Raw Link header value, forwarded verbatim — it is not a bare URL. Format: <https://\{datacenter\}/receipts/v4/status/\{receiptId\}>; rel="processing-status". Parse the href out of the angle brackets before using it. | diff --git a/apps/sim/app/api/tools/sap_concur/proxy/route.ts b/apps/sim/app/api/tools/sap_concur/proxy/route.ts index 802d83be266..efb207ab48f 100644 --- a/apps/sim/app/api/tools/sap_concur/proxy/route.ts +++ b/apps/sim/app/api/tools/sap_concur/proxy/route.ts @@ -1,15 +1,20 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { + MAX_JSON_API_RESPONSE_BYTES, + secureFetchWithValidation, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { assertSafeExternalUrl, + describeSapConcurFetchError, extractSapConcurError, fetchSapConcurAccessToken, + forwardedSapConcurHeaders, SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, type SapConcurProxyRequest, SapConcurProxyRequestSchema, @@ -39,10 +44,52 @@ function buildApiUrl(geolocation: string, req: ProxyRequest): string { return url.includes('?') ? `${url}&${queryString}` : `${url}?${queryString}` } +/** + * Map a non-2xx Concur status that cannot be re-emitted as an error status onto 502. + * + * With `maxRedirects: 0` a 3xx carrying a `Location` never reaches here — it rejects with + * "Too many redirects" and is handled in the outer catch. What does reach here is a 3xx + * *without* a `Location`, and a 304, which is excluded from the redirect handling + * upstream. Neither is a usable error status to return to the caller. + */ +function clampErrorStatus(status: number): number { + return status >= 400 ? status : 502 +} + interface Invocation { status: number body: unknown raw: string + /** Concur response headers forwarded onto this route's response. */ + headers: Record +} + +/** + * Invoke a Concur API endpoint with the bearer token. + * + * `concur-correlationid` is a support/tracing header expected to be a fresh RFC 4122 + * UUID per request; it does not scope a request to a company. Redirects are refused so + * the Authorization header is never forwarded to another origin. + * + * `stripAuthOnRedirect` is unreachable while `maxRedirects` is 0 — no redirect is ever + * followed for it to act on. It is kept as defense-in-depth so raising `maxRedirects` + * later cannot silently start forwarding the bearer token; do not remove it as dead code. + */ +/** + * Read a Concur response body, keeping the upstream status meaningful. + * + * On a success status the body is the result, so a stream failure is a real + * error and must propagate. On an error status the body only supplies the + * message, and throwing would turn Concur's 4xx into a Sim 500 — the status is + * preserved instead and the message falls back to the generic HTTP-status form. + */ +export async function readConcurProxyBody(response: { + status: number + text: () => Promise +}): Promise { + const read = response.text() + if (response.status >= 200 && response.status < 300) return read + return read.catch(() => '') } async function callConcur( @@ -54,10 +101,10 @@ async function callConcur( const hasBody = req.body !== undefined && req.body !== null const headers: Record = { Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', + Accept: req.accept ?? 'application/json', } if (hasBody) headers['Content-Type'] = req.contentType ?? 'application/json' - if (req.companyUuid) headers['concur-correlationid'] = req.companyUuid + headers['concur-correlationid'] = generateId() const response = await secureFetchWithValidation( url, @@ -70,11 +117,14 @@ async function callConcur( : JSON.stringify(req.body) : undefined, timeout: SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, + maxRedirects: 0, + stripAuthOnRedirect: true, + maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, }, 'apiUrl' ) - const raw = await response.text() + const raw = await readConcurProxyBody(response) let parsed: unknown = null if (raw.length > 0) { try { @@ -83,7 +133,12 @@ async function callConcur( parsed = raw } } - return { status: response.status, body: parsed, raw } + return { + status: response.status, + body: parsed, + raw, + headers: forwardedSapConcurHeaders(response.headers), + } } export const POST = withRouteHandler(async (request: NextRequest) => { @@ -108,7 +163,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (invocation.status >= 200 && invocation.status < 300) { const data = invocation.status === 204 ? null : invocation.body - return NextResponse.json({ success: true, output: { status: invocation.status, data } }) + return NextResponse.json( + { success: true, output: { status: invocation.status, data } }, + { headers: invocation.headers } + ) } const message = extractSapConcurError(invocation.body, invocation.status) @@ -117,7 +175,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) return NextResponse.json( { success: false, error: message, status: invocation.status }, - { status: invocation.status } + { status: clampErrorStatus(invocation.status), headers: invocation.headers } ) } catch (error) { if (isZodError(error)) { @@ -128,6 +186,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } logger.error(`[${requestId}] Unexpected Concur proxy error:`, error) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: describeSapConcurFetchError(error) }, + { status: 500 } + ) } }) diff --git a/apps/sim/app/api/tools/sap_concur/response-body.test.ts b/apps/sim/app/api/tools/sap_concur/response-body.test.ts new file mode 100644 index 00000000000..ed5389bb7c3 --- /dev/null +++ b/apps/sim/app/api/tools/sap_concur/response-body.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReadResponseTextWithLimit, mockSecureFetch, MOCK_MAX_JSON_BYTES } = vi.hoisted(() => ({ + mockReadResponseTextWithLimit: vi.fn(), + mockSecureFetch: vi.fn(), + MOCK_MAX_JSON_BYTES: 10 * 1024 * 1024, +})) + +vi.mock('@/lib/core/utils/stream-limits', () => { + class PayloadSizeLimitError extends Error { + observedBytes?: number + constructor(message: string, observedBytes?: number) { + super(message) + this.name = 'PayloadSizeLimitError' + this.observedBytes = observedBytes + } + } + return { + PayloadSizeLimitError, + readResponseTextWithLimit: mockReadResponseTextWithLimit, + } +}) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithValidation: mockSecureFetch, + MAX_JSON_API_RESPONSE_BYTES: MOCK_MAX_JSON_BYTES, +})) + +import { readConcurProxyBody } from '@/app/api/tools/sap_concur/proxy/route' +import { readConcurUploadBody } from '@/app/api/tools/sap_concur/upload/route' + +/** Minimal response shape both helpers accept. */ +function uploadResponse(status: number): Parameters[0] { + return { + status, + headers: new Headers(), + body: null, + } +} + +function proxyResponse( + status: number, + text: () => Promise +): Parameters[0] { + return { status, text } +} + +beforeEach(() => { + vi.clearAllMocks() + mockReadResponseTextWithLimit.mockReset() +}) + +/** + * Both helpers make the same success/error split, so the cases are declared once and run + * against each helper. `readConcurUploadBody` reads through the mocked + * `readResponseTextWithLimit`; `readConcurProxyBody` reads through `response.text()`. + */ +const helpers = [ + { + name: 'readConcurUploadBody', + read: (status: number, result: Promise) => { + mockReadResponseTextWithLimit.mockReturnValue(result) + return readConcurUploadBody(uploadResponse(status)) + }, + }, + { + name: 'readConcurProxyBody', + read: (status: number, result: Promise) => + readConcurProxyBody(proxyResponse(status, () => result)), + }, +] as const + +describe.each(helpers)('$name response body reads', ({ read }) => { + it('resolves with the body text on a success status', async () => { + await expect(read(200, Promise.resolve('{"id":"exp-1"}'))).resolves.toBe('{"id":"exp-1"}') + }) + + it('resolves with an empty string for an empty success body', async () => { + await expect(read(200, Promise.resolve(''))).resolves.toBe('') + }) + + it('propagates a read failure on a success status', async () => { + const failure = new Error('Concur upload response exceeded 10485760 bytes') + await expect(read(201, Promise.reject(failure))).rejects.toBe(failure) + }) + + it('resolves with the body text on an error status', async () => { + await expect(read(400, Promise.resolve('{"message":"Invalid userId"}'))).resolves.toBe( + '{"message":"Invalid userId"}' + ) + }) + + it('swallows a read failure on a 4xx status', async () => { + await expect(read(403, Promise.reject(new Error('stream aborted')))).resolves.toBe('') + }) + + it('swallows a read failure on a 5xx status', async () => { + await expect(read(503, Promise.reject(new Error('stream aborted')))).resolves.toBe('') + }) + + /** + * The source compares `status >= 200 && status < 300`, so 200 and 299 take the strict + * path and 199 and 300 take the tolerant one. + */ + it.each([200, 299])('treats %i as a success status', async (status) => { + const failure = new Error('read failed') + await expect(read(status, Promise.reject(failure))).rejects.toBe(failure) + }) + + it.each([199, 300])('treats %i as a non-success status', async (status) => { + await expect(read(status, Promise.reject(new Error('read failed')))).resolves.toBe('') + }) +}) + +describe('readConcurUploadBody byte cap wiring', () => { + it('reads under the shared JSON response byte cap', async () => { + mockReadResponseTextWithLimit.mockReturnValue(Promise.resolve('{}')) + const response = uploadResponse(200) + + await expect(readConcurUploadBody(response)).resolves.toBe('{}') + + expect(mockReadResponseTextWithLimit).toHaveBeenCalledWith(response, { + maxBytes: MOCK_MAX_JSON_BYTES, + label: 'Concur upload response', + }) + }) +}) diff --git a/apps/sim/app/api/tools/sap_concur/shared.test.ts b/apps/sim/app/api/tools/sap_concur/shared.test.ts new file mode 100644 index 00000000000..259f1c6f9ee --- /dev/null +++ b/apps/sim/app/api/tools/sap_concur/shared.test.ts @@ -0,0 +1,906 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSecureFetch, MOCK_MAX_JSON_BYTES } = vi.hoisted(() => ({ + mockSecureFetch: vi.fn(), + MOCK_MAX_JSON_BYTES: 10 * 1024 * 1024, +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithValidation: mockSecureFetch, + MAX_JSON_API_RESPONSE_BYTES: MOCK_MAX_JSON_BYTES, +})) + +import { + assertSafeExternalUrl, + extractSapConcurError, + fetchSapConcurAccessToken, + forwardedSapConcurHeaders, + SAP_CONCUR_ALLOWED_DATACENTERS, + type SapConcurAuth, + SapConcurDatacenterSchema, + SapConcurProxyPath, + SapConcurProxyRequestSchema, +} from '@/app/api/tools/sap_concur/shared' + +const CLIENT_SECRET = 'super-secret-client-value' +const PASSWORD = 'hunter2-plaintext-password' + +/** + * `TOKEN_CACHE` in shared.ts is module-global and survives every test in this file, so + * each case takes its own `clientId`. That guarantees a cold cache key for the case and + * keeps one test's cached token from silently satisfying the next test's assertions. + */ +let clientIdCounter = 0 +function freshClientId(): string { + clientIdCounter += 1 + return `client-${clientIdCounter}` +} + +function auth(overrides: Partial & { clientId: string }): SapConcurAuth { + return { + datacenter: 'us.api.concursolutions.com', + grantType: 'client_credentials', + clientSecret: CLIENT_SECRET, + ...overrides, + } +} + +function tokenResponse( + body: Record = { access_token: 'token-1', expires_in: 3600 }, + status = 200 +) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + json: async () => body, + text: async () => JSON.stringify(body), + } +} + +beforeEach(() => { + vi.clearAllMocks() + // mockReset also drains any `mockResolvedValueOnce` a failing test left queued. + mockSecureFetch.mockReset() + mockSecureFetch.mockResolvedValue(tokenResponse()) +}) + +describe('fetchSapConcurAccessToken token cache key isolation', () => { + /** + * Regression test for the auth-bypass: with the password absent from the cache key, a + * request carrying the wrong password was served a token minted from the correct one. + */ + it('does not share a cache entry across differing passwords', async () => { + const clientId = freshClientId() + const base = auth({ + clientId, + grantType: 'password', + username: 'alice@example.com', + }) + + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-correct', expires_in: 3600 }) + ) + const first = await fetchSapConcurAccessToken({ ...base, password: PASSWORD }, 'req-1') + + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-other', expires_in: 3600 }) + ) + const second = await fetchSapConcurAccessToken({ ...base, password: 'a-different-pw' }, 'req-2') + + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + expect(first.accessToken).toBe('token-correct') + expect(second.accessToken).toBe('token-other') + }) + + it('does not share a cache entry across differing companyUuid', async () => { + const clientId = freshClientId() + const base = auth({ clientId }) + + await fetchSapConcurAccessToken({ ...base, companyUuid: 'company-a' }, 'req-1') + await fetchSapConcurAccessToken({ ...base, companyUuid: 'company-b' }, 'req-2') + + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + }) + + it('does not share a cache entry across differing credtype', async () => { + const clientId = freshClientId() + const base = auth({ + clientId, + grantType: 'password', + username: 'alice@example.com', + password: PASSWORD, + }) + + await fetchSapConcurAccessToken({ ...base, credtype: 'password' }, 'req-1') + await fetchSapConcurAccessToken({ ...base, credtype: 'authtoken' }, 'req-2') + + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + }) + + it('shares the cache for two fully identical requests', async () => { + const clientId = freshClientId() + const base = auth({ + clientId, + grantType: 'password', + username: 'alice@example.com', + password: PASSWORD, + companyUuid: 'company-a', + credtype: 'password', + }) + + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-cached', expires_in: 3600 }) + ) + const first = await fetchSapConcurAccessToken({ ...base }, 'req-1') + const second = await fetchSapConcurAccessToken({ ...base }, 'req-2') + + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(first.accessToken).toBe('token-cached') + expect(second.accessToken).toBe('token-cached') + }) + + it('refetches once a cached token falls inside the 60s safety window', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const base = auth({ clientId: freshClientId() }) + + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-first', expires_in: 120 }) + ) + const first = await fetchSapConcurAccessToken(base, 'req-1') + expect(first.accessToken).toBe('token-first') + + // 30s in: still outside the 60s safety window, so the cache answers. + vi.setSystemTime(new Date('2026-01-01T00:00:30.000Z')) + const cached = await fetchSapConcurAccessToken(base, 'req-2') + expect(cached.accessToken).toBe('token-first') + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + + // 90s in: expiry (120s) minus the 60s window has passed, so it refetches. + vi.setSystemTime(new Date('2026-01-01T00:01:30.000Z')) + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-second', expires_in: 3600 }) + ) + const refreshed = await fetchSapConcurAccessToken(base, 'req-3') + expect(refreshed.accessToken).toBe('token-second') + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) +}) + +/** + * A parallel block fanning out many Concur calls, or a cold container after a deploy, + * misses the token cache on every branch at once. Without coalescing each branch fires + * its own `POST /oauth2/v0/token` into an endpoint Concur rate-limits hard. + */ +describe('fetchSapConcurAccessToken in-flight coalescing', () => { + it('collapses concurrent misses for one key into a single token fetch', async () => { + const base = auth({ clientId: freshClientId() }) + + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + mockSecureFetch.mockImplementation(async () => { + await gate + return tokenResponse({ access_token: 'token-shared', expires_in: 3600 }) + }) + + const inFlight = Array.from({ length: 8 }, (_, index) => + fetchSapConcurAccessToken(base, `req-${index}`) + ) + release() + const results = await Promise.all(inFlight) + + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + for (const result of results) { + expect(result.accessToken).toBe('token-shared') + } + }) + + it('does not collapse concurrent misses for different keys', async () => { + const first = auth({ clientId: freshClientId() }) + const second = auth({ clientId: freshClientId() }) + + await Promise.all([ + fetchSapConcurAccessToken(first, 'req-1'), + fetchSapConcurAccessToken(second, 'req-2'), + ]) + + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + }) + + it('does not poison the key when the in-flight request rejects', async () => { + const base = auth({ clientId: freshClientId() }) + + mockSecureFetch.mockRejectedValueOnce(new Error('socket hang up')) + const first = fetchSapConcurAccessToken(base, 'req-1') + const joiner = fetchSapConcurAccessToken(base, 'req-2') + + await expect(first).rejects.toThrow('socket hang up') + await expect(joiner).rejects.toThrow('socket hang up') + + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-after-retry', expires_in: 3600 }) + ) + const retried = await fetchSapConcurAccessToken(base, 'req-3') + + expect(retried.accessToken).toBe('token-after-retry') + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + }) +}) + +describe('fetchSapConcurAccessToken geolocation validation', () => { + const accepted = [ + 'https://us.api.concursolutions.com', + 'https://www-us2.api.concursolutions.com', + 'https://apj1.api.concursolutions.com', + 'https://emea-impl.api.concursolutions.com', + ] + + it.each(accepted)('accepts the Concur geolocation %s', async (geolocation) => { + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-1', expires_in: 3600, geolocation }) + ) + const result = await fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + expect(result.geolocation).toBe(geolocation) + }) + + const rejected: Array<[string, string]> = [ + ['an unrelated host', 'https://evil.com'], + ['a suffix-confusion host', 'https://concursolutions.com.evil.com'], + ['a subdomain-confusion host', 'https://us.api.concursolutions.com.evil.com'], + ] + + it.each(rejected)('rejects %s', async (_label, geolocation) => { + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-1', expires_in: 3600, geolocation }) + ) + await expect( + fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + ).rejects.toThrow('not a valid Concur API host') + }) + + it('rejects a plain-http geolocation', async () => { + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ + access_token: 'token-1', + expires_in: 3600, + geolocation: 'http://us.api.concursolutions.com', + }) + ) + await expect( + fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + ).rejects.toThrow('geolocation must use https://') + }) + + it('rejects a loopback geolocation', async () => { + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-1', expires_in: 3600, geolocation: 'https://127.0.0.1' }) + ) + await expect( + fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + ).rejects.toThrow('geolocation host is not allowed') + }) + + it('normalizes a bare hostname to https and still validates it', async () => { + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ + access_token: 'token-1', + expires_in: 3600, + geolocation: 'us2.api.concursolutions.com', + }) + ) + const result = await fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + expect(result.geolocation).toBe('https://us2.api.concursolutions.com') + }) + + it('rejects a bare hostname that normalizes to a non-Concur host', async () => { + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-1', expires_in: 3600, geolocation: 'evil.com' }) + ) + await expect( + fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + ).rejects.toThrow('not a valid Concur API host') + }) + + /** + * DOCUMENTED TRUST ASSUMPTION, asserted as current behavior on purpose: the geolocation + * check validates the *shape* `[label].api.concursolutions.com`, not membership in + * {@link SAP_CONCUR_ALLOWED_DATACENTERS}. That is deliberate — Concur's docs instruct + * clients to store and reuse whatever geolocation the token response returns, and SAP + * adds datacenters (GLZ was one) without clients redeploying, so pinning the response + * to the selectable set would break tenants on a new datacenter. + * + * The consequence is that an attacker-flavored label like `evil-us` is accepted. Such a + * host can only exist if SAP itself creates it under concursolutions.com, which puts it + * inside the same trust boundary as every other Concur host. Narrowing this to the + * allowlist is a deliberate product decision, not a bug fix — do not "harden" it + * without re-reading the geolocation guidance in the authentication docs. + */ + it('accepts any SAP-created label under api.concursolutions.com (trust boundary is the domain)', async () => { + const geolocation = 'https://evil-us.api.concursolutions.com' + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-1', expires_in: 3600, geolocation }) + ) + const result = await fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + expect(result.geolocation).toBe(geolocation) + }) + + it('rejects a userinfo-form geolocation whose real hostname is attacker-controlled', async () => { + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ + access_token: 'token-1', + expires_in: 3600, + geolocation: 'https://us.api.concursolutions.com@evil.com', + }) + ) + await expect( + fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + ).rejects.toThrow('not a valid Concur API host') + }) + + it('accepts a Concur host carrying an explicit port and preserves it', async () => { + const geolocation = 'https://us.api.concursolutions.com:8443' + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ access_token: 'token-1', expires_in: 3600, geolocation }) + ) + const result = await fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + expect(result.geolocation).toBe(geolocation) + }) + + it('rejects a non-Concur host even when the port looks Concur-shaped', async () => { + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ + access_token: 'token-1', + expires_in: 3600, + geolocation: 'https://evil.com:443', + }) + ) + await expect( + fetchSapConcurAccessToken(auth({ clientId: freshClientId() }), 'req-1') + ).rejects.toThrow('not a valid Concur API host') + }) +}) + +/** + * Concur's company-level flow is a password grant that carries the company UUID in + * `username`, the 24-hour App Center request token in `password`, and `credtype=authtoken`. + */ +describe('fetchSapConcurAccessToken company-level auth', () => { + function submittedParams(): URLSearchParams { + const [, init] = mockSecureFetch.mock.calls[0] + return new URLSearchParams(init.body as string) + } + + it('submits the companyUuid as username and defaults credtype to authtoken', async () => { + await fetchSapConcurAccessToken( + auth({ + clientId: freshClientId(), + grantType: 'password', + password: 'company-request-token', + companyUuid: '08BCCA1E-0D4F-4261-9F1B-F778D96617D6', + }), + 'req-1' + ) + + const params = submittedParams() + expect(params.get('grant_type')).toBe('password') + expect(params.get('username')).toBe('08BCCA1E-0D4F-4261-9F1B-F778D96617D6') + expect(params.get('password')).toBe('company-request-token') + expect(params.get('credtype')).toBe('authtoken') + }) + + it('lets an explicit credtype override the company-flow default', async () => { + await fetchSapConcurAccessToken( + auth({ + clientId: freshClientId(), + grantType: 'password', + password: 'company-request-token', + companyUuid: 'company-uuid-1', + credtype: 'password', + }), + 'req-1' + ) + + expect(submittedParams().get('credtype')).toBe('password') + }) + + it('prefers the companyUuid over a supplied username', async () => { + await fetchSapConcurAccessToken( + auth({ + clientId: freshClientId(), + grantType: 'password', + username: 'alice@example.com', + password: 'company-request-token', + companyUuid: 'company-uuid-2', + }), + 'req-1' + ) + + expect(submittedParams().get('username')).toBe('company-uuid-2') + }) + + it('leaves the user-level password grant untouched (no credtype, real username)', async () => { + await fetchSapConcurAccessToken( + auth({ + clientId: freshClientId(), + grantType: 'password', + username: 'alice@example.com', + password: PASSWORD, + }), + 'req-1' + ) + + const params = submittedParams() + expect(params.get('username')).toBe('alice@example.com') + expect(params.has('credtype')).toBe(false) + }) + + it('requires a username or a companyUuid for a password grant', async () => { + await expect( + fetchSapConcurAccessToken( + auth({ clientId: freshClientId(), grantType: 'password', password: PASSWORD }), + 'req-1' + ) + ).rejects.toThrow('username is required for password grant') + }) +}) + +describe('fetchSapConcurAccessToken secret handling', () => { + it('never puts the clientSecret or password into a token-fetch error message', async () => { + mockSecureFetch.mockResolvedValueOnce( + tokenResponse({ error: 'invalid_grant', error_description: 'Bad credentials' }, 401) + ) + + const promise = fetchSapConcurAccessToken( + auth({ + clientId: freshClientId(), + grantType: 'password', + username: 'alice@example.com', + password: PASSWORD, + }), + 'req-1' + ) + + await expect(promise).rejects.toThrow('Concur token request failed: invalid_grant') + const error = await promise.catch((e: Error) => e) + expect(error.message).not.toContain(CLIENT_SECRET) + expect(error.message).not.toContain(PASSWORD) + }) + + /** + * The token request body is form-encoded `client_id=…&client_secret=…&password=…`, so an + * intermediary that rejects the request and echoes it back would otherwise have its page + * surfaced verbatim. The raw fallback is capped on the token path for that reason. + */ + it('truncates an unstructured token-error body instead of echoing it back', async () => { + const echoedRequest = `Request blocked by proxy. Your request was: POST /oauth2/v0/token client_id=abc&client_secret=${CLIENT_SECRET}&grant_type=password&username=alice@example.com&password=${PASSWORD}&credtype=password. Contact your administrator with reference id 0000-1111-2222-3333 for further assistance with this policy decision.` + + mockSecureFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + headers: new Headers(), + json: async () => ({}), + text: async () => echoedRequest, + }) + + const error = await fetchSapConcurAccessToken( + auth({ + clientId: freshClientId(), + grantType: 'password', + username: 'alice@example.com', + password: PASSWORD, + }), + 'req-1' + ).catch((e: Error) => e) + + expect(error.message).not.toContain(CLIENT_SECRET) + expect(error.message).not.toContain(PASSWORD) + expect(error.message.length).toBeLessThan(echoedRequest.length) + expect(error.message).toContain('Request blocked by proxy') + }) + + it('never leaks credentials when the outbound fetch itself throws', async () => { + mockSecureFetch.mockRejectedValueOnce(new Error('socket hang up')) + + const error = await fetchSapConcurAccessToken( + auth({ + clientId: freshClientId(), + grantType: 'password', + username: 'alice@example.com', + password: PASSWORD, + }), + 'req-1' + ).catch((e: Error) => e) + + expect(error.message).not.toContain(CLIENT_SECRET) + expect(error.message).not.toContain(PASSWORD) + }) +}) + +describe('SapConcurProxyPath', () => { + const accepted = [ + '/expensereports/v4/reports/abc123', + 'expensereports/v4/reports/abc123', + '/profile/v1/principals/1234-5678', + ] + + it.each(accepted)('accepts the ordinary path %s', (path) => { + expect(SapConcurProxyPath.safeParse(path).success).toBe(true) + }) + + const rejected = [ + '/expensereports/../../etc/passwd', + '/expensereports/./v4/reports', + '..', + '/expensereports\\..\\v4', + '/expensereports/v4#fragment', + '/expensereports/%2e%2e/v4', + '/expensereports/%2E%2E/v4', + '/expensereports%2fv4', + '/expensereports%5cv4', + '/expensereports%23v4', + ] + + it.each(rejected)('rejects the traversal-shaped path %s', (path) => { + expect(SapConcurProxyPath.safeParse(path).success).toBe(false) + }) + + /** + * KNOWN LIMITATION, asserted as current behavior on purpose: the refine only inspects + * one layer of percent-encoding, so a double-encoded `%252e%252e` passes. That is + * acceptable because the refine is defense-in-depth — the request host is still pinned + * by `assertSafeExternalUrl` against the validated Concur geolocation, so a decoded + * `..` can at worst walk within concursolutions.com and cannot reach another origin. + * Do not "fix" this here without re-checking the host pinning that backs it. + */ + it('does not reject double-encoded traversal (defense-in-depth, host is pinned elsewhere)', () => { + expect(SapConcurProxyPath.safeParse('/expensereports/%252e%252e/v4').success).toBe(true) + }) +}) + +describe('SapConcurProxyRequestSchema accept', () => { + function parse(overrides: Record) { + return SapConcurProxyRequestSchema.parse({ + clientId: 'client-1', + clientSecret: CLIENT_SECRET, + path: '/expensereports/v4/reports', + ...overrides, + }) + } + + it('leaves accept undefined so callConcur applies its application/json default', () => { + expect(parse({}).accept).toBeUndefined() + }) + + /** Itinerary and Travel Profile are XML-only and 406 an Accept they cannot satisfy. */ + it('carries an explicit XML accept through', () => { + expect(parse({ accept: 'application/xml' }).accept).toBe('application/xml') + }) +}) + +/** + * The executor retries 429/5xx for a block with a retry config and paces itself off + * `Retry-After`; dropping the header downgrades a precise wait to blind backoff. + */ +describe('forwardedSapConcurHeaders', () => { + it('forwards Retry-After, Location, and Link', () => { + expect( + forwardedSapConcurHeaders( + new Headers({ + 'Retry-After': '30', + Location: 'https://us.api.concursolutions.com/receipts/v4/receipts/abc', + Link: '; rel="next"', + }) + ) + ).toEqual({ + 'retry-after': '30', + location: 'https://us.api.concursolutions.com/receipts/v4/receipts/abc', + link: '; rel="next"', + }) + }) + + it('omits headers Concur did not send', () => { + expect(forwardedSapConcurHeaders(new Headers({ 'Retry-After': '5' }))).toEqual({ + 'retry-after': '5', + }) + }) + + it('forwards nothing when no interesting header is present', () => { + expect(forwardedSapConcurHeaders(new Headers({ 'Content-Type': 'application/json' }))).toEqual( + {} + ) + }) +}) + +describe('SapConcurDatacenterSchema', () => { + /** Every host in the published Base URIs table, plus the legacy `eu`/`emea` aliases. */ + const accepted = [ + 'us.api.concursolutions.com', + 'www-us.api.concursolutions.com', + 'us2.api.concursolutions.com', + 'www-us2.api.concursolutions.com', + 'eu.api.concursolutions.com', + 'eu2.api.concursolutions.com', + 'www-eu2.api.concursolutions.com', + 'emea.api.concursolutions.com', + 'www-emea.api.concursolutions.com', + 'apj1.api.concursolutions.com', + 'www-apj1.api.concursolutions.com', + 'usg.api.concursolutions.com', + 'www-usg.api.concursolutions.com', + 'glz.api.concursolutions.com', + 'us-impl.api.concursolutions.com', + 'www-us-impl.api.concursolutions.com', + 'emea-impl.api.concursolutions.com', + 'www-emea-impl.api.concursolutions.com', + ] + + it.each(accepted)('accepts the documented datacenter %s', (datacenter) => { + expect(SapConcurDatacenterSchema.safeParse(datacenter).success).toBe(true) + }) + + it('covers exactly the documented set with no extras', () => { + expect([...SAP_CONCUR_ALLOWED_DATACENTERS].sort()).toEqual([...accepted].sort()) + }) + + /** GLZ is the one production row the Base URIs table publishes without a `www-` twin. */ + it('does not offer a www- twin for GLZ', () => { + expect(SapConcurDatacenterSchema.safeParse('www-glz.api.concursolutions.com').success).toBe( + false + ) + }) + + const rejected = [ + 'evil.com', + 'us.api.concursolutions.com.evil.com', + 'evil-us.api.concursolutions.com', + 'https://us.api.concursolutions.com', + ] + + it.each(rejected)('rejects the non-selectable datacenter %s', (datacenter) => { + expect(SapConcurDatacenterSchema.safeParse(datacenter).success).toBe(false) + }) +}) + +describe('assertSafeExternalUrl', () => { + it('accepts a normal Concur https URL', () => { + const url = assertSafeExternalUrl('https://us.api.concursolutions.com/expense/v4', 'apiUrl') + expect(url.hostname).toBe('us.api.concursolutions.com') + }) + + it('rejects a non-URL', () => { + expect(() => assertSafeExternalUrl('not a url', 'apiUrl')).toThrow('must be a valid URL') + }) + + it('rejects a non-https scheme', () => { + expect(() => assertSafeExternalUrl('http://us.api.concursolutions.com', 'apiUrl')).toThrow( + 'must use https://' + ) + }) + + const forbiddenHosts = [ + 'https://localhost/x', + 'https://0.0.0.0/x', + 'https://127.0.0.1/x', + 'https://169.254.169.254/latest/meta-data/', + 'https://metadata.google.internal/x', + 'https://[::1]/x', + ] + + it.each(forbiddenHosts)('rejects the metadata/loopback host %s', (url) => { + expect(() => assertSafeExternalUrl(url, 'apiUrl')).toThrow('is not allowed') + }) + + const privateIps = ['https://10.0.0.5/x', 'https://192.168.1.10/x', 'https://172.16.4.4/x'] + + it.each(privateIps)('rejects the private IP %s', (url) => { + expect(() => assertSafeExternalUrl(url, 'apiUrl')).toThrow('private/loopback range') + }) +}) + +describe('extractSapConcurError', () => { + it('combines the OAuth error and error_description', () => { + expect( + extractSapConcurError({ error: 'invalid_client', error_description: 'Bad client id' }, 401) + ).toBe('invalid_client: Bad client id') + }) + + it('includes the Expense v4 errorMessage with its validation details', () => { + const message = extractSapConcurError( + { + errorMessage: 'Report is not valid', + validationErrors: [{ message: 'purpose is required' }, { message: 'amount must be > 0' }], + }, + 400 + ) + expect(message).toContain('Report is not valid') + expect(message).toContain('purpose is required') + expect(message).toContain('amount must be > 0') + }) + + it('returns a bare Expense v4 errorMessage when there are no validation errors', () => { + expect(extractSapConcurError({ errorMessage: 'Report is not valid' }, 400)).toBe( + 'Report is not valid' + ) + }) + + it('prefixes the SCIM detail with the scimType', () => { + expect( + extractSapConcurError({ scimType: 'invalidValue', detail: 'userName already exists' }, 409) + ).toBe('[invalidValue] userName already exists') + }) + + it('returns a SCIM detail without a scimType', () => { + expect(extractSapConcurError({ detail: 'userName already exists' }, 409)).toBe( + 'userName already exists' + ) + }) + + it('reads the legacy nested Content.Error.Message envelope', () => { + expect( + extractSapConcurError({ Content: { Error: { Message: 'Invalid report key' } } }, 400) + ).toBe('Invalid report key') + }) + + it('reads the legacy top-level Error.Message envelope', () => { + expect(extractSapConcurError({ Error: { Message: 'Invalid itinerary' } }, 400)).toBe( + 'Invalid itinerary' + ) + }) + + it('includes the token-error code alongside the OAuth error', () => { + expect( + extractSapConcurError( + { code: 16, error: 'invalid_request', error_description: 'user lives elsewhere' }, + 400 + ) + ).toBe('[16] invalid_request: user lives elsewhere') + }) + + it('accepts a string-typed token-error code', () => { + expect(extractSapConcurError({ code: '53', error: 'invalid_grant' }, 400)).toBe( + '[53] invalid_grant' + ) + }) + + /** Budget v4 (Budget Category) failure response, verbatim from the API reference. */ + it('joins the Budget v4 errorMessageList with its types and codes', () => { + expect( + extractSapConcurError( + { + status: false, + errorMessageList: [ + { + errorType: 'ERROR', + errorCode: 'BUDGET.BUDGET_CATEGORY_NAME_REQUIRED', + errorMessage: 'Budget category name is required', + }, + { + errorType: 'ERROR', + errorCode: 'BUDGET.BUDGET_CATEGORY_NAME_UNIQUE_ERROR', + errorMessage: 'Budget category must have a unique name', + }, + ], + }, + 400 + ) + ).toBe( + '[ERROR BUDGET.BUDGET_CATEGORY_NAME_REQUIRED] Budget category name is required; ' + + '[ERROR BUDGET.BUDGET_CATEGORY_NAME_UNIQUE_ERROR] Budget category must have a unique name' + ) + }) + + /** Budget Adjustments v4 nests the same object one level down under `message`. */ + it('unwraps an object-valued message to reach a nested errorMessageList', () => { + expect( + extractSapConcurError( + { + message: { + status: false, + errorMessageList: [ + { + errorType: 'ERROR', + errorCode: 'BUDGET.BUDGET_PERIOD_REQUIRED', + errorMessage: 'Record 1) Budget period is missing', + }, + ], + }, + }, + 400 + ) + ).toBe('[ERROR BUDGET.BUDGET_PERIOD_REQUIRED] Record 1) Budget period is missing') + }) + + it('still prefers a string-valued message over the legacy envelope', () => { + expect(extractSapConcurError({ message: 'Report not found' }, 404)).toBe('Report not found') + }) + + it('falls back to the Concur SCIM messages extension when detail is absent', () => { + expect( + extractSapConcurError( + { + schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], + status: '400', + 'urn:ietf:params:scim:api:messages:concur:2.0:Error': { + messages: [ + { + code: 'ATTRIBUTE_REQUIRED', + message: 'userName is required', + schemaPath: 'userName', + type: 'error', + }, + ], + }, + }, + 400 + ) + ).toBe('[ATTRIBUTE_REQUIRED] userName is required (userName)') + }) + + it('prefers the SCIM detail over the messages extension when both are present', () => { + expect( + extractSapConcurError( + { + detail: 'userName already exists', + 'urn:ietf:params:scim:api:messages:concur:2.0:Error': { + messages: [{ code: 'DUP', message: 'duplicate', type: 'error' }], + }, + }, + 409 + ) + ).toBe('userName already exists') + }) + + it('joins an errors list with its error codes', () => { + expect( + extractSapConcurError( + { + errors: [ + { errorCode: 'E1', errorMessage: 'first problem' }, + { errorCode: 'E2', errorMessage: 'second problem' }, + ], + }, + 400 + ) + ).toBe('[E1] first problem; [E2] second problem') + }) + + it('passes a raw string body through when no cap is set', () => { + expect(extractSapConcurError('Service Unavailable', 503)).toBe('Service Unavailable') + }) + + it('caps a raw string body when maxRawBodyLength is set', () => { + expect(extractSapConcurError('x'.repeat(500), 503, { maxRawBodyLength: 20 })).toBe( + `${'x'.repeat(20)}...` + ) + }) + + it('leaves a structured body uncapped even when maxRawBodyLength is set', () => { + expect(extractSapConcurError({ error: 'invalid_client' }, 401, { maxRawBodyLength: 5 })).toBe( + 'invalid_client' + ) + }) + + it('falls back to the generic HTTP message for an unrecognized shape', () => { + expect(extractSapConcurError({ unexpected: true }, 418)).toBe( + 'Concur request failed with HTTP 418' + ) + }) + + it('falls back to the generic HTTP message for an empty body', () => { + expect(extractSapConcurError('', 500)).toBe('Concur request failed with HTTP 500') + }) +}) + +afterEach(() => { + vi.useRealTimers() +}) diff --git a/apps/sim/app/api/tools/sap_concur/shared.ts b/apps/sim/app/api/tools/sap_concur/shared.ts index 9936c436b4b..8c7adf82665 100644 --- a/apps/sim/app/api/tools/sap_concur/shared.ts +++ b/apps/sim/app/api/tools/sap_concur/shared.ts @@ -1,21 +1,55 @@ import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { isPrivateIpHost } from '@sim/security/ssrf' +import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { z } from 'zod' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { coalesceLocally } from '@/lib/concurrency/singleflight' +import { + MAX_JSON_API_RESPONSE_BYTES, + secureFetchWithValidation, +} from '@/lib/core/security/input-validation.server' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' const logger = createLogger('SapConcurShared') +/** + * User-selectable hosts for the token request, from SAP Concur's published Base URIs + * (production, GLZ, APJ, US Gov) and Implementation sandbox hosts. + * + * Every datacenter is published twice: the bare host "optionally requests certs" and is + * the server-side form, while the `www-` twin "does not request certs" and is the form + * the docs point browser/client callers at. Both are legitimate token hosts, so both are + * selectable. GLZ (Global Landing Zone) is the one production row with no `www-` twin. + * + * This set constrains only the datacenter the caller picks; the geolocation Concur + * returns is validated by shape via {@link SAP_CONCUR_GEOLOCATION_HOST_PATTERN}, since + * the docs instruct clients to store and reuse whatever geolocation comes back. + */ export const SAP_CONCUR_ALLOWED_DATACENTERS = new Set([ 'us.api.concursolutions.com', + 'www-us.api.concursolutions.com', 'us2.api.concursolutions.com', + 'www-us2.api.concursolutions.com', 'eu.api.concursolutions.com', 'eu2.api.concursolutions.com', - 'cn.api.concursolutions.com', + 'www-eu2.api.concursolutions.com', 'emea.api.concursolutions.com', + 'www-emea.api.concursolutions.com', + 'apj1.api.concursolutions.com', + 'www-apj1.api.concursolutions.com', + 'usg.api.concursolutions.com', + 'www-usg.api.concursolutions.com', + 'glz.api.concursolutions.com', + 'us-impl.api.concursolutions.com', + 'www-us-impl.api.concursolutions.com', + 'emea-impl.api.concursolutions.com', + 'www-emea-impl.api.concursolutions.com', ]) +/** Documented host form for a Concur geolocation, including `www-` prefixed variants. */ +const SAP_CONCUR_GEOLOCATION_HOST_PATTERN = /^(www-)?[a-z0-9-]+\.api\.concursolutions\.com$/ + export const SapConcurDatacenterSchema = z .string() .min(1) @@ -32,7 +66,18 @@ export const SapConcurAuthSchema = z.object({ clientSecret: z.string().min(1, 'clientSecret is required'), username: z.string().optional(), password: z.string().optional(), + /** + * Company UUID for the company-level password grant. When set, it is submitted as the + * `username` form field and `credtype` defaults to `authtoken`. See + * {@link fetchSapConcurAccessToken} for the full flow. + */ companyUuid: z.string().optional(), + /** + * Which credential set is submitted with a password grant. Concur defaults to + * `password` when the form param is absent, so it is only sent when set explicitly or + * implied by {@link SapConcurAuthSchema} `companyUuid`. + */ + credtype: z.enum(['password', 'authtoken']).optional(), }) export type SapConcurAuth = z.infer @@ -59,13 +104,19 @@ export const SapConcurProxyRequestSchema = SapConcurAuthSchema.extend({ query: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), body: z.unknown().optional(), contentType: z.string().optional(), + /** + * Media type sent as the outbound `Accept` header, defaulting to `application/json`. + * The Itinerary and Travel Profile APIs are XML-only and 406 a JSON-only Accept, so + * those tools set `application/xml` explicitly. + */ + accept: z.string().optional(), }).superRefine((req, ctx) => { if (req.grantType === 'password') { - if (!req.username) { + if (!req.username && !req.companyUuid) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['username'], - message: 'username is required for password grant', + message: 'username is required for password grant (or companyUuid for company-level auth)', }) } if (!req.password) { @@ -90,7 +141,6 @@ export const SapConcurUploadRequestSchema = SapConcurAuthSchema.extend({ userId: z.string().min(1, 'userId is required'), contextType: z.string().optional(), receipt: FileInputSchema, - forwardId: z.string().max(40).optional(), body: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(), }) @@ -136,19 +186,73 @@ interface CachedToken { expiresAt: number } +/** Access token plus the geolocation every subsequent API call for it must be sent to. */ +export interface SapConcurToken { + accessToken: string + geolocation: string +} + const TOKEN_CACHE = new Map() const TOKEN_CACHE_MAX_ENTRIES = 500 const TOKEN_SAFETY_WINDOW_MS = 60_000 export const SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS = 30_000 +/** + * Namespace for the process-wide single-flight map so a Concur token cache key cannot + * collide with another subsystem's coalescing key. + */ +const SAP_CONCUR_TOKEN_COALESCE_PREFIX = 'sap-concur:token:' + +/** + * Settle deadline for a coalesced token request, held just above the outbound fetch + * timeout so that timeout is what normally fires. The extra margin covers the response + * read and geolocation validation, and guarantees joiners are released even if the token + * request somehow outlives its own timeout. + */ +const SAP_CONCUR_TOKEN_COALESCE_TIMEOUT_MS = SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS + 5_000 + +/** Cached token for `key`, or `undefined` when absent or inside the expiry safety window. */ +function readCachedToken(key: string): SapConcurToken | undefined { + const cached = TOKEN_CACHE.get(key) + if (!cached || cached.expiresAt - TOKEN_SAFETY_WINDOW_MS <= Date.now()) return undefined + return { accessToken: cached.accessToken, geolocation: cached.geolocation } +} + +/** + * Cache key covering every factor that authenticates the token request. The password + * and company UUID must participate: without them a cache hit skips the token endpoint + * entirely, so a request carrying the wrong password would be served a token minted from + * someone else's correct credentials out of this module-global cache. + * + * The whole tuple is JSON-encoded before hashing rather than concatenated with a + * separator, so a free-form field (clientId, companyUuid) cannot span a field boundary + * and collide with a different tuple. The full sha256 digest is kept — truncating it + * would lower the collision/forgery bar for no measurable gain. + */ function tokenCacheKey(req: SapConcurAuth): string { - const secretHash = createHash('sha256').update(req.clientSecret).digest('hex').slice(0, 16) - const userHash = req.username - ? createHash('sha256').update(req.username).digest('hex').slice(0, 12) - : '' - return `${req.datacenter}::${req.grantType}::${req.clientId}::${secretHash}::${userHash}` + return createHash('sha256') + .update( + JSON.stringify([ + req.datacenter, + req.grantType, + req.clientId, + req.clientSecret, + req.username ?? '', + req.password ?? '', + req.companyUuid ?? '', + req.credtype ?? '', + ]) + ) + .digest('hex') } +/** + * Insert a token and evict from the front once the cache is over its cap. + * + * Eviction is FIFO by insertion order, not LRU — a cache *read* does not move an entry + * back. At 500 entries that is deliberate: a token is short-lived and re-minted on the + * next miss, so the extra bookkeeping an LRU needs buys nothing here. + */ function rememberToken(key: string, token: CachedToken): void { if (TOKEN_CACHE.has(key)) TOKEN_CACHE.delete(key) TOKEN_CACHE.set(key, token) @@ -166,25 +270,104 @@ function normalizeGeolocation(raw: string | undefined, fallback: string): string return `https://${trimmed}` } +/** + * Cap for an unstructured token-endpoint error body, applied both to the log line and to + * the surfaced message. The token request body is form-encoded + * `client_id=…&client_secret=…&password=…`, so an intermediary (WAF, proxy, captive + * portal) that echoes the request it rejected would otherwise have its page returned to + * the caller verbatim. Structured Concur error shapes are unaffected — they are matched + * before the raw fallback is reached. + */ +const TOKEN_ERROR_RAW_BODY_MAX_LENGTH = 200 + +/** + * Blank out the credential values this module just submitted, wherever they appear in an + * error body. + * + * Truncation alone bounds the exposure but does not remove it — a secret can sit inside + * the surviving prefix. Because the exact values are known at the callsite, they can be + * substituted out precisely. A genuine Concur error body never contains them, so this is + * a no-op for every documented shape and only bites on an intermediary echoing our + * request back at us. + */ +function redactTokenSecrets(text: string, auth: SapConcurAuth): string { + if (!text) return text + let redacted = text + for (const secret of [auth.clientSecret, auth.password]) { + if (secret && secret.length > 0) redacted = redacted.split(secret).join('[redacted]') + } + return redacted +} + +/** Best-effort JSON parse of an error body, falling back to the raw text. */ +function parseMaybeJson(text: string): unknown { + if (!text) return '' + try { + return JSON.parse(text) + } catch { + return text + } +} + /** * Acquire a Concur access token, sharing a cache with the proxy route. * Validates that the geolocation returned by Concur is a safe external URL. + * + * Misses are coalesced per cache key: a parallel block fanning out many Concur calls, or + * a cold container after a deploy, would otherwise fire one `POST /oauth2/v0/token` per + * branch into an endpoint Concur rate-limits hard. Coalescing also removes an + * interleaving hazard — with concurrent mints, a slow response settling last could cache + * an earlier-expiring token over a fresher one. + * + * Two password-grant shapes are supported: + * + * - User-level: `username` is the user's login and `password` their password. `credtype` + * is omitted, which Concur reads as its `password` default. + * - Company-level: when `companyUuid` is set, Concur's documented company flow is + * `grant_type=password&username=&password=&credtype=authtoken`. + * The company UUID is submitted as `username` and `credtype` defaults to `authtoken`. + * An explicitly supplied `credtype` always wins. + * + * KNOWN LIMITATION of the company flow: the company request token obtained from the App + * Center is valid for 24 hours only, and Concur returns a `refresh_token` alongside the + * access token so the connection can outlive it. Refresh-token exchange is not + * implemented here, so a company connection stops working once the request token expires + * and a fresh one must be issued. + * + * Token-endpoint failures carry `{ code, error, error_description, geolocation? }`. + * Code 16 ("user lives elsewhere") additionally returns the correct geolocation for the + * tenant; retrying the token request against that host is not implemented here. */ export async function fetchSapConcurAccessToken( auth: SapConcurAuth, requestId: string -): Promise<{ accessToken: string; geolocation: string }> { +): Promise { if (auth.grantType === 'password') { - if (!auth.username) throw new Error('username is required for password grant') + if (!auth.username && !auth.companyUuid) { + throw new Error( + 'username is required for password grant (or companyUuid for company-level auth)' + ) + } if (!auth.password) throw new Error('password is required for password grant') } const cacheKey = tokenCacheKey(auth) - const cached = TOKEN_CACHE.get(cacheKey) - if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { - return { accessToken: cached.accessToken, geolocation: cached.geolocation } - } + const cached = readCachedToken(cacheKey) + if (cached) return cached + + return coalesceLocally( + `${SAP_CONCUR_TOKEN_COALESCE_PREFIX}${cacheKey}`, + async () => readCachedToken(cacheKey) ?? (await requestAccessToken(auth, requestId, cacheKey)), + SAP_CONCUR_TOKEN_COALESCE_TIMEOUT_MS + ) +} +/** Mint a fresh token from the Concur token endpoint and cache it under `cacheKey`. */ +async function requestAccessToken( + auth: SapConcurAuth, + requestId: string, + cacheKey: string +): Promise { const tokenUrl = assertSafeExternalUrl( `https://${auth.datacenter}/oauth2/v0/token`, 'tokenUrl' @@ -195,9 +378,11 @@ export async function fetchSapConcurAccessToken( params.set('client_secret', auth.clientSecret) params.set('grant_type', auth.grantType) if (auth.grantType === 'password') { - params.set('username', auth.username ?? '') + const companyUuid = auth.companyUuid + params.set('username', companyUuid ?? auth.username ?? '') params.set('password', auth.password ?? '') - if (auth.companyUuid) params.set('credtype', 'authtoken') + const credtype = auth.credtype ?? (companyUuid ? 'authtoken' : undefined) + if (credtype) params.set('credtype', credtype) } const response = await secureFetchWithValidation( @@ -210,14 +395,26 @@ export async function fetchSapConcurAccessToken( }, body: params.toString(), timeout: SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, + maxRedirects: 0, + stripAuthOnRedirect: true, + maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, }, 'tokenUrl' ) if (!response.ok) { - const text = await response.text().catch(() => '') - logger.warn(`[${requestId}] Concur token fetch failed (${response.status}): ${text}`) - throw new Error(`Concur token request failed: HTTP ${response.status}`) + const text = redactTokenSecrets(await response.text().catch(() => ''), auth) + logger.warn( + `[${requestId}] Concur token fetch failed (${response.status}): ${truncate( + text, + TOKEN_ERROR_RAW_BODY_MAX_LENGTH + )}` + ) + throw new Error( + `Concur token request failed: ${extractSapConcurError(parseMaybeJson(text), response.status, { + maxRawBodyLength: TOKEN_ERROR_RAW_BODY_MAX_LENGTH, + })}` + ) } const data = (await response.json()) as { @@ -232,9 +429,9 @@ export async function fetchSapConcurAccessToken( const geolocation = normalizeGeolocation(data.geolocation, auth.datacenter) const geolocationUrl = assertSafeExternalUrl(geolocation, 'geolocation') - if (!SAP_CONCUR_ALLOWED_DATACENTERS.has(geolocationUrl.hostname.toLowerCase())) { + if (!SAP_CONCUR_GEOLOCATION_HOST_PATTERN.test(geolocationUrl.hostname.toLowerCase())) { throw new Error( - `Concur geolocation host is not in the allowed datacenter list: ${geolocationUrl.hostname}` + `Concur geolocation host is not a valid Concur API host: ${geolocationUrl.hostname}` ) } @@ -247,33 +444,198 @@ export async function fetchSapConcurAccessToken( return { accessToken: data.access_token, geolocation } } -/** Extract a meaningful error message from a Concur error response body. */ -export function extractSapConcurError(body: unknown, status: number): string { - if (body && typeof body === 'object') { - const obj = body as Record - if (typeof obj.error === 'string' && obj.error.length > 0) { - const desc = typeof obj.error_description === 'string' ? `: ${obj.error_description}` : '' - return `${obj.error}${desc}` - } - if (typeof obj.message === 'string' && obj.message.length > 0) { - return obj.message - } - const errors = obj.errors - if (Array.isArray(errors) && errors.length > 0) { - return errors - .map((e) => { - if (e && typeof e === 'object') { - const eo = e as Record - const code = typeof eo.errorCode === 'string' ? `[${eo.errorCode}] ` : '' - const msg = typeof eo.errorMessage === 'string' ? eo.errorMessage : '' - return `${code}${msg}`.trim() - } - return String(e) - }) - .filter(Boolean) - .join('; ') - } +/** + * Concur response headers carried through onto the route's own response. + * + * `Retry-After` is the load-bearing one: the executor retries 429/5xx for a block with a + * retry config and paces itself off this header, so dropping it downgrades a precise wait + * into blind exponential backoff against an endpoint that just told us how long to wait. + * `Location` and `Link` identify the resource created by, or the next page of, an + * accepted request. + */ +const FORWARDED_CONCUR_HEADERS = ['retry-after', 'location', 'link'] as const + +/** + * Pick the {@link FORWARDED_CONCUR_HEADERS} present on a Concur response. + * + * Typed structurally rather than as `Headers` so it accepts both a DOM `Headers` and the + * `SecureFetchHeaders` returned by `secureFetchWithValidation`, which exposes only `get`. + */ +export function forwardedSapConcurHeaders(source: { + get(name: string): string | null +}): Record { + const forwarded: Record = {} + for (const name of FORWARDED_CONCUR_HEADERS) { + const value = source.get(name) + if (value) forwarded[name] = value + } + return forwarded +} + +/** + * Turn an outbound-fetch rejection into a message a caller can act on. + * + * `secureFetchWithValidation` runs with `maxRedirects: 0`, so any Concur response that is + * a redirect *with* a `Location` header rejects with `Too many redirects (max: 0)` rather + * than returning a status. That is a deliberate refusal (the bearer token must never be + * replayed to another origin), but the bare message reads like an internal fault, so it + * is restated in terms of what actually happened. + */ +export function describeSapConcurFetchError(error: unknown): string { + const message = getErrorMessage(error, 'Unknown error') + if (message.startsWith('Too many redirects')) { + return 'Concur returned a redirect, which is not followed because the access token must not be replayed to another origin. Check the datacenter/geolocation and the request path.' + } + return message +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +/** + * Message from the legacy nested envelope used by Expense v3 and Travel: + * `{ Content: { Error: { Message } } }` or `{ Error: { Message } }`. + */ +function legacyEnvelopeMessage(obj: Record): string | undefined { + const container = isRecord(obj.Content) ? obj.Content : obj + const error = isRecord(container.Error) ? container.Error : undefined + return error ? nonEmptyString(error.Message) : undefined +} + +/** URN of the Concur SCIM error extension carrying per-attribute `messages[]`. */ +const SCIM_CONCUR_ERROR_URN = 'urn:ietf:params:scim:api:messages:concur:2.0:Error' + +/** + * Render one Budget v4 `errorMessageList` entry (`{ errorType, errorCode, errorMessage }`). + * `errorType` is kept because it distinguishes a hard `ERROR` from a `WARNING`. + */ +function formatErrorMessageListEntry(entry: unknown): string { + if (!isRecord(entry)) return String(entry) + const label = [nonEmptyString(entry.errorType), nonEmptyString(entry.errorCode)] + .filter(Boolean) + .join(' ') + const message = nonEmptyString(entry.errorMessage) ?? '' + return label ? `[${label}] ${message}`.trim() : message +} + +/** Render one Concur SCIM extension message (`{ code, message, schemaPath, type }`). */ +function formatScimExtensionMessage(entry: unknown): string { + if (!isRecord(entry)) return String(entry) + const code = nonEmptyString(entry.code) + const message = nonEmptyString(entry.message) ?? '' + const schemaPath = nonEmptyString(entry.schemaPath) + const head = code ? `[${code}] ` : '' + const tail = schemaPath ? ` (${schemaPath})` : '' + return `${head}${message}${tail}`.trim() +} + +function joinNonEmpty(values: unknown[], format: (value: unknown) => string): string | undefined { + const joined = values.map(format).filter(Boolean).join('; ') + return joined.length > 0 ? joined : undefined +} + +/** + * Match a Concur error record against the documented shapes, returning `undefined` when + * none apply so the caller can fall through to its own default. + * + * `depth` bounds the single documented level of nesting: Budget Adjustments v4 wraps the + * same `{ status, errorMessageList }` object under a `message` key, so an object-valued + * `message` is unwrapped once before the string-valued `message` shape is considered. + */ +function extractFromRecord(obj: Record, depth: number): string | undefined { + if (depth === 0 && isRecord(obj.message)) { + const nested = extractFromRecord(obj.message, depth + 1) + if (nested) return nested + } + + const error = nonEmptyString(obj.error) + if (error) { + const description = nonEmptyString(obj.error_description) + const code = obj.code + const codePrefix = typeof code === 'string' || typeof code === 'number' ? `[${code}] ` : '' + return `${codePrefix}${error}${description ? `: ${description}` : ''}` + } + + const errorMessage = nonEmptyString(obj.errorMessage) + if (errorMessage) { + const validationErrors = Array.isArray(obj.validationErrors) + ? obj.validationErrors + .map((v) => (isRecord(v) ? nonEmptyString(v.message) : undefined)) + .filter((m): m is string => Boolean(m)) + : [] + return validationErrors.length > 0 + ? `${errorMessage}: ${validationErrors.join('; ')}` + : errorMessage + } + + if (Array.isArray(obj.errorMessageList) && obj.errorMessageList.length > 0) { + const joined = joinNonEmpty(obj.errorMessageList, formatErrorMessageListEntry) + if (joined) return joined + } + + const detail = nonEmptyString(obj.detail) + if (detail) { + const scimType = nonEmptyString(obj.scimType) + return scimType ? `[${scimType}] ${detail}` : detail + } + + const scimExtension = obj[SCIM_CONCUR_ERROR_URN] + if (isRecord(scimExtension) && Array.isArray(scimExtension.messages)) { + const joined = joinNonEmpty(scimExtension.messages, formatScimExtensionMessage) + if (joined) return joined + } + + const message = nonEmptyString(obj.message) + if (message) return message + + const legacy = legacyEnvelopeMessage(obj) + if (legacy) return legacy + + if (Array.isArray(obj.errors) && obj.errors.length > 0) { + return joinNonEmpty(obj.errors, (e) => { + if (!isRecord(e)) return String(e) + const code = nonEmptyString(e.errorCode) + const msg = nonEmptyString(e.errorMessage) ?? '' + return `${code ? `[${code}] ` : ''}${msg}`.trim() + }) + } + + return undefined +} + +interface ExtractSapConcurErrorOptions { + /** + * Cap applied to an unstructured string body before it is surfaced. Set on the token + * path, where the request body carries credentials an intermediary might echo back. + * Left unset elsewhere so ordinary API errors surface in full. + */ + maxRawBodyLength?: number +} + +/** + * Extract a meaningful error message from a Concur error response body, covering the + * OAuth `{ code, error, error_description }` shape, the Expense v4 `ErrorMessage` schema, + * the Budget v4 `errorMessageList` shape (including the Budget Adjustments v4 variant + * that nests it under `message`), the SCIM (Identity v4.1) `detail` shape and its Concur + * `messages[]` extension, the legacy nested `Content.Error.Message` envelope, and an + * undocumented `{ errors: [...] }` list kept for tolerance. + */ +export function extractSapConcurError( + body: unknown, + status: number, + options: ExtractSapConcurErrorOptions = {} +): string { + if (isRecord(body)) { + const message = extractFromRecord(body, 0) + if (message) return message + } + if (typeof body === 'string' && body.length > 0) { + return options.maxRawBodyLength === undefined ? body : truncate(body, options.maxRawBodyLength) } - if (typeof body === 'string' && body.length > 0) return body return `Concur request failed with HTTP ${status}` } diff --git a/apps/sim/app/api/tools/sap_concur/upload/route.ts b/apps/sim/app/api/tools/sap_concur/upload/route.ts index 4a682b3c5d7..df51d1db074 100644 --- a/apps/sim/app/api/tools/sap_concur/upload/route.ts +++ b/apps/sim/app/api/tools/sap_concur/upload/route.ts @@ -1,10 +1,15 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { + MAX_JSON_API_RESPONSE_BYTES, + secureFetchWithValidation, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { PayloadSizeLimitError, readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -12,8 +17,10 @@ import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' import { assertSafeExternalUrl, + describeSapConcurFetchError, extractSapConcurError, fetchSapConcurAccessToken, + forwardedSapConcurHeaders, SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, type SapConcurUploadRequest, SapConcurUploadRequestSchema, @@ -32,6 +39,7 @@ const RECEIPT_ALLOWED_MIME_TYPES = new Set([ 'image/jpg', 'image/gif', 'image/tiff', + 'image/tif', ]) const QUICK_EXPENSE_ALLOWED_MIME_TYPES = new Set([ @@ -40,13 +48,85 @@ const QUICK_EXPENSE_ALLOWED_MIME_TYPES = new Set([ 'image/jpeg', 'image/jpg', 'image/tiff', + 'image/tif', ]) const ALLOWED_MIME_TYPES = RECEIPT_ALLOWED_MIME_TYPES +/** + * Concur's documented ceiling for image-only receipts: Receipts "Supported Image Formats" + * states "Image size must not exceed 25MB." + */ +const MAX_RECEIPT_IMAGE_BYTES = 25 * 1024 * 1024 + +/** + * Concur's documented ceiling for quick-expense images: the Quick Expense v4 + * `fileContent` parameter states "Maximum size 50 MB." + * + * A file this large is held in memory more than once on the upload path — the downloaded + * `Buffer`, the `Blob` copy inside the `FormData`, and the serialized multipart `Buffer`. + * What bounds that is the `userFile.size` check performed *before* the download, together + * with the `maxBytes` passed to `downloadServableFileFromStorage`; a file over the cap is + * rejected without ever being materialized. Keep both checks ahead of the download. + */ +const MAX_QUICK_EXPENSE_IMAGE_BYTES = 50 * 1024 * 1024 + +function maxImageBytesForOperation(operation: UploadRequest['operation']): number { + return operation === 'create_quick_expense_with_image' + ? MAX_QUICK_EXPENSE_IMAGE_BYTES + : MAX_RECEIPT_IMAGE_BYTES +} + +function uploadSizeError(bytes: number, maxBytes: number): NextResponse { + const sizeMB = (bytes / (1024 * 1024)).toFixed(2) + const limitMB = Math.round(maxBytes / (1024 * 1024)) + return NextResponse.json( + { + success: false, + error: `File size (${sizeMB}MB) exceeds Concur upload limit of ${limitMB}MB`, + }, + { status: 400 } + ) +} + +/** + * Map a non-2xx Concur status that cannot be re-emitted as an error status onto 502. + * + * With `maxRedirects: 0` a 3xx carrying a `Location` never reaches here — it rejects with + * "Too many redirects" and is handled in the outer catch. What does reach here is a 3xx + * *without* a `Location`, and a 304, which is excluded from the redirect handling + * upstream. Neither is a usable error status to return to the caller. + */ +function clampErrorStatus(status: number): number { + return status >= 400 ? status : 502 +} + +/** Sentinel {@link inferMimeType} returns when neither the declared type nor the extension resolves. */ +const UNKNOWN_MIME_TYPE = 'application/octet-stream' + +function unsupportedMimeTypeError(mimeType: string, allowedLabel: string): NextResponse { + return NextResponse.json( + { + success: false, + error: `Unsupported receipt mime type: ${mimeType}. Allowed: ${allowedLabel}`, + }, + { status: 400 } + ) +} + +/** + * Non-canonical media types Concur callers commonly declare, mapped to the canonical form + * the allowlists and the outbound `Blob` type use. + */ +const MIME_TYPE_ALIASES: Record = { + 'image/jpg': 'image/jpeg', + 'image/tif': 'image/tiff', +} + function inferMimeType(name: string, declared?: string): string { if (declared && ALLOWED_MIME_TYPES.has(declared.toLowerCase())) { - return declared.toLowerCase() === 'image/jpg' ? 'image/jpeg' : declared.toLowerCase() + const lowerDeclared = declared.toLowerCase() + return MIME_TYPE_ALIASES[lowerDeclared] ?? lowerDeclared } const lower = name.toLowerCase() if (lower.endsWith('.pdf')) return 'application/pdf' @@ -54,7 +134,7 @@ function inferMimeType(name: string, declared?: string): string { if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg' if (lower.endsWith('.gif')) return 'image/gif' if (lower.endsWith('.tif') || lower.endsWith('.tiff')) return 'image/tiff' - return 'application/octet-stream' + return UNKNOWN_MIME_TYPE } function stringifyMaybeJson(value: unknown): string { @@ -65,21 +145,59 @@ function stringifyMaybeJson(value: unknown): string { interface UploadInvocation { status: number body: unknown + /** Concur response headers forwarded onto this route's response. */ + headers: Record +} + +/** + * POST a multipart body to Concur with the bearer token. + * + * `concur-correlationid` is a support/tracing header expected to be a fresh RFC 4122 + * UUID per request; it does not scope a request to a company. Redirects are refused so + * the Authorization header is never forwarded to another origin. + * + * `stripAuthOnRedirect` is unreachable while `maxRedirects` is 0 — no redirect is ever + * followed for it to act on. It is kept as defense-in-depth so raising `maxRedirects` + * later cannot silently start forwarding the bearer token; do not remove it as dead code. + */ +/** + * Read a Concur upload response body under the shared byte cap. + * + * On a success status the body is the result, so a cap breach or a stream + * failure is a real error and must propagate — swallowing it would report an + * incomplete exchange as a successful upload with no data. + * + * On an error status the body only supplies the message, and the upstream + * status is the more important signal: letting a failed read throw here would + * surface Concur's 4xx as a Sim 500 and invite a retry the caller should not + * make. The status is preserved and the message falls back to the generic + * HTTP-status form from {@link extractSapConcurError}. + */ +export async function readConcurUploadBody(response: { + status: number + headers?: { get(name: string): string | null } + body?: ReadableStream | null + arrayBuffer?: () => Promise + text?: () => Promise +}): Promise { + const read = readResponseTextWithLimit(response, { + maxBytes: MAX_JSON_API_RESPONSE_BYTES, + label: 'Concur upload response', + }) + if (response.status >= 200 && response.status < 300) return read + return read.catch(() => '') } async function postMultipart( url: string, accessToken: string, - formData: FormData, - companyUuid: string | undefined, - extraHeaders?: Record + formData: FormData ): Promise { const headers: Record = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', - ...(extraHeaders ?? {}), + 'concur-correlationid': generateId(), } - if (companyUuid) headers['concur-correlationid'] = companyUuid // Serialize FormData (with auto-generated multipart boundary) to a Buffer so we can // route through secureFetchWithValidation (which doesn't support FormData bodies directly). @@ -98,11 +216,14 @@ async function postMultipart( headers, body: bodyBuffer, timeout: SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, + maxRedirects: 0, + stripAuthOnRedirect: true, + maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, }, 'apiUrl' ) - const raw = await response.text() + const raw = await readConcurUploadBody(response) let parsed: unknown = null if (raw.length > 0) { try { @@ -122,7 +243,11 @@ async function postMultipart( parsed = { location, link } } } - return { status: response.status, body: parsed } + return { + status: response.status, + body: parsed, + headers: forwardedSapConcurHeaders(response.headers), + } } async function handleUploadReceiptImage( @@ -141,11 +266,7 @@ async function handleUploadReceiptImage( const formData = new FormData() formData.append('image', new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), fileName) - const extraHeaders: Record | undefined = req.forwardId - ? { 'concur-forwardid': req.forwardId } - : undefined - - return postMultipart(url, accessToken, formData, req.companyUuid, extraHeaders) + return postMultipart(url, accessToken, formData) } async function handleCreateQuickExpenseWithImage( @@ -174,7 +295,7 @@ async function handleCreateQuickExpenseWithImage( fileName ) - return postMultipart(url, accessToken, formData, req.companyUuid) + return postMultipart(url, accessToken, formData) } export const POST = withRouteHandler(async (request: NextRequest) => { @@ -209,40 +330,55 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) if (denied) return denied + + const maxBytes = maxImageBytesForOperation(uploadReq.operation) + const allowedForOperation = + uploadReq.operation === 'create_quick_expense_with_image' + ? QUICK_EXPENSE_ALLOWED_MIME_TYPES + : RECEIPT_ALLOWED_MIME_TYPES + const allowedLabel = + uploadReq.operation === 'create_quick_expense_with_image' + ? 'pdf, png, jpeg, tiff' + : 'pdf, png, jpeg, gif, tiff' + + if (userFile.size > maxBytes) { + return uploadSizeError(userFile.size, maxBytes) + } + + const declaredMimeType = inferMimeType(userFile.name, userFile.type) + if (declaredMimeType !== UNKNOWN_MIME_TYPE && !allowedForOperation.has(declaredMimeType)) { + return unsupportedMimeTypeError(declaredMimeType, allowedLabel) + } + let fileBuffer: Buffer let resolvedContentType: string try { - const resolved = await downloadServableFileFromStorage(userFile, requestId, logger) + const resolved = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes, + }) fileBuffer = resolved.buffer resolvedContentType = resolved.contentType } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (error instanceof PayloadSizeLimitError) { + return uploadSizeError(error.observedBytes ?? userFile.size, maxBytes) + } logger.error(`[${requestId}] Failed to download Concur receipt file:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } + + if (fileBuffer.length > maxBytes) { + return uploadSizeError(fileBuffer.length, maxBytes) + } + const fileName = userFile.name const mimeType = inferMimeType(fileName, resolvedContentType || userFile.type) - - const allowedForOperation = - uploadReq.operation === 'create_quick_expense_with_image' - ? QUICK_EXPENSE_ALLOWED_MIME_TYPES - : RECEIPT_ALLOWED_MIME_TYPES if (!allowedForOperation.has(mimeType)) { - const allowedLabel = - uploadReq.operation === 'create_quick_expense_with_image' - ? 'pdf, png, jpeg, tiff' - : 'pdf, png, jpeg, gif, tiff' - return NextResponse.json( - { - success: false, - error: `Unsupported receipt mime type: ${mimeType}. Allowed: ${allowedLabel}`, - }, - { status: 400 } - ) + return unsupportedMimeTypeError(mimeType, allowedLabel) } const { accessToken, geolocation } = await fetchSapConcurAccessToken(uploadReq, requestId) @@ -273,7 +409,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info( `[${requestId}] Concur ${uploadReq.operation} succeeded: HTTP ${invocation.status}` ) - return NextResponse.json({ success: true, output: { status: invocation.status, data } }) + return NextResponse.json( + { success: true, output: { status: invocation.status, data } }, + { headers: invocation.headers } + ) } const message = extractSapConcurError(invocation.body, invocation.status) @@ -282,7 +421,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) return NextResponse.json( { success: false, error: message, status: invocation.status }, - { status: invocation.status } + { status: clampErrorStatus(invocation.status), headers: invocation.headers } ) } catch (error) { if (isZodError(error)) { @@ -293,6 +432,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } logger.error(`[${requestId}] Unexpected Concur upload error:`, error) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: describeSapConcurFetchError(error) }, + { status: 500 } + ) } }) diff --git a/apps/sim/blocks/blocks/sap_concur.ts b/apps/sim/blocks/blocks/sap_concur.ts index db85721800e..0641b80d334 100644 --- a/apps/sim/blocks/blocks/sap_concur.ts +++ b/apps/sim/blocks/blocks/sap_concur.ts @@ -12,7 +12,6 @@ const toBool = (v: unknown): boolean | undefined => { } const REPORT_USER_OPS = [ - 'sap_concur_list_expense_reports', 'sap_concur_get_expense_report', 'sap_concur_create_expense_report', 'sap_concur_update_expense_report', @@ -40,30 +39,26 @@ const REPORT_USER_OPS = [ const REPORT_GET_CONTEXT_TYPE_OPS = ['sap_concur_get_expense_report'] const EXPENSE_READ_CONTEXT_TYPE_OPS = [ - 'sap_concur_list_expense_reports', - 'sap_concur_list_expenses', 'sap_concur_get_expense', - 'sap_concur_get_itemizations', 'sap_concur_list_exceptions', + 'sap_concur_list_report_comments', + 'sap_concur_create_report_comment', ] -const QUICK_EXPENSE_CONTEXT_TYPE_OPS = [ +const TRAVELER_ONLY_CONTEXT_TYPE_OPS = [ + 'sap_concur_list_expenses', + 'sap_concur_get_itemizations', 'sap_concur_create_quick_expense', 'sap_concur_create_quick_expense_with_image', ] -const MANAGER_ONLY_CONTEXT_TYPE_OPS = ['sap_concur_list_reports_to_approve'] - const ATTENDEE_CONTEXT_TYPE_OPS = [ 'sap_concur_list_attendee_associations', 'sap_concur_associate_attendees', 'sap_concur_remove_all_attendees', - 'sap_concur_create_report_comment', - 'sap_concur_list_report_comments', ] const ALLOCATION_CONTEXT_TYPE_OPS = [ - 'sap_concur_list_allocations', 'sap_concur_get_allocation', 'sap_concur_update_allocation', 'sap_concur_recall_expense_report', @@ -71,6 +66,47 @@ const ALLOCATION_CONTEXT_TYPE_OPS = [ 'sap_concur_update_expense_report', ] +const LIST_ALLOCATIONS_CONTEXT_TYPE_OPS = ['sap_concur_list_allocations'] + +/** Every `contextType` subBlock variant shares one state key, so a value picked under one + * operation survives a switch to an operation whose dropdown never offered it. This maps each + * operation to the values its own dropdown exposes so the stored value can be clamped. */ +const CONTEXT_TYPE_ALLOWED_VALUES: Record = {} +for (const [ops, allowed] of [ + [REPORT_GET_CONTEXT_TYPE_OPS, ['TRAVELER', 'MANAGER', 'PROCESSOR', 'PROXY']], + [EXPENSE_READ_CONTEXT_TYPE_OPS, ['TRAVELER', 'MANAGER', 'PROXY']], + [TRAVELER_ONLY_CONTEXT_TYPE_OPS, ['TRAVELER']], + [LIST_ALLOCATIONS_CONTEXT_TYPE_OPS, ['TRAVELER', 'MANAGER']], + [ALLOCATION_CONTEXT_TYPE_OPS, ['TRAVELER', 'PROXY']], + [ATTENDEE_CONTEXT_TYPE_OPS, ['TRAVELER', 'PROXY']], +] as const) { + for (const op of ops) CONTEXT_TYPE_ALLOWED_VALUES[op] = allowed +} + +/** Default context every operation's dropdown offers. */ +const DEFAULT_CONTEXT_TYPE = 'TRAVELER' + +/** Clamps the shared `contextType` state to the values the given operation actually accepts. */ +const clampContextType = (operation: unknown, value: unknown): string => { + const allowed = CONTEXT_TYPE_ALLOWED_VALUES[String(operation)] + if (!allowed) return DEFAULT_CONTEXT_TYPE + return typeof value === 'string' && allowed.includes(value) ? value : DEFAULT_CONTEXT_TYPE +} + +/** List Lists and List List Items share one `sortBy` state key but accept disjoint sort fields, + * so a value picked under one operation is illegal under the other and must be clamped away. */ +const SORT_BY_ALLOWED_VALUES: Record = { + sap_concur_list_lists: ['name', 'levelcount', 'listcategory'], + sap_concur_list_list_items: ['value', 'shortCode'], +} + +/** Clamps the shared `sortBy` state to the fields the given operation accepts, else unset. */ +const clampSortBy = (operation: unknown, value: unknown): string | undefined => { + const allowed = SORT_BY_ALLOWED_VALUES[String(operation)] + if (!allowed || typeof value !== 'string' || !allowed.includes(value)) return undefined + return value +} + const REPORT_ID_OPS = [ 'sap_concur_get_expense_report', 'sap_concur_update_expense_report', @@ -130,8 +166,6 @@ const LIST_ITEM_ID_OPS = [ const BODY_OPS = [ 'sap_concur_create_expense_report', 'sap_concur_update_expense_report', - 'sap_concur_submit_expense_report', - 'sap_concur_recall_expense_report', 'sap_concur_approve_expense_report', 'sap_concur_send_back_expense_report', 'sap_concur_update_expense', @@ -164,7 +198,7 @@ export const SapConcurBlock: BlockConfig = { description: 'Manage expense reports, travel requests, cash advances, and more in SAP Concur', authMode: AuthMode.ApiKey, longDescription: - 'Connect SAP Concur via OAuth 2.0. Manage expense reports and line items, allocations, attendees, comments, exceptions, quick expenses, receipts, travel requests and expected expenses, cash advances, itineraries, user identities, custom lists, budgets, exchange rates, and purchase requests across every Concur datacenter.', + 'Connect SAP Concur with an OAuth client ID and secret (client-credentials or password grant) — no account linking required. Manage expense reports and line items, allocations, attendees, comments, exceptions, quick expenses, receipts, travel requests and expected expenses, cash advances, itineraries, user identities, custom lists, budgets, exchange rates, and purchase requests across every Concur datacenter.', docsLink: 'https://docs.sim.ai/integrations/sap_concur', category: 'tools', integrationType: IntegrationType.Productivity, @@ -359,12 +393,7 @@ export const SapConcurBlock: BlockConfig = { ], sap_concur_get_itinerary: [{ text: 'Read trip', field: 'tripId', core: true }], sap_concur_list_users: [ - { - text: 'List', - field: 'count', - after: 'user identities', - core: true, - }, + 'List user identities', { text: ', returning', field: 'attributes' }, ], sap_concur_get_user: [{ text: 'Read user', field: 'userUuid', core: true }], @@ -520,12 +549,79 @@ export const SapConcurBlock: BlockConfig = { title: 'Datacenter', type: 'dropdown', options: [ - { label: 'US (us.api.concursolutions.com)', id: 'us.api.concursolutions.com' }, - { label: 'US 2 (us2.api.concursolutions.com)', id: 'us2.api.concursolutions.com' }, - { label: 'EU (eu.api.concursolutions.com)', id: 'eu.api.concursolutions.com' }, - { label: 'EU 2 (eu2.api.concursolutions.com)', id: 'eu2.api.concursolutions.com' }, - { label: 'EMEA (emea.api.concursolutions.com)', id: 'emea.api.concursolutions.com' }, - { label: 'CN (cn.api.concursolutions.com)', id: 'cn.api.concursolutions.com' }, + { + label: 'US — may request client cert (us.api.concursolutions.com)', + id: 'us.api.concursolutions.com', + }, + { + label: 'US — no client cert (www-us.api.concursolutions.com)', + id: 'www-us.api.concursolutions.com', + }, + { + label: 'US 2 — may request client cert (us2.api.concursolutions.com)', + id: 'us2.api.concursolutions.com', + }, + { + label: 'US 2 — no client cert (www-us2.api.concursolutions.com)', + id: 'www-us2.api.concursolutions.com', + }, + { + label: 'EU — may request client cert (eu.api.concursolutions.com)', + id: 'eu.api.concursolutions.com', + }, + { + label: 'EU 2 — may request client cert (eu2.api.concursolutions.com)', + id: 'eu2.api.concursolutions.com', + }, + { + label: 'EU 2 — no client cert (www-eu2.api.concursolutions.com)', + id: 'www-eu2.api.concursolutions.com', + }, + { + label: 'EMEA — may request client cert (emea.api.concursolutions.com)', + id: 'emea.api.concursolutions.com', + }, + { + label: 'EMEA — no client cert (www-emea.api.concursolutions.com)', + id: 'www-emea.api.concursolutions.com', + }, + { + label: 'APJ — may request client cert (apj1.api.concursolutions.com)', + id: 'apj1.api.concursolutions.com', + }, + { + label: 'APJ — no client cert (www-apj1.api.concursolutions.com)', + id: 'www-apj1.api.concursolutions.com', + }, + { + label: 'US Gov — may request client cert (usg.api.concursolutions.com)', + id: 'usg.api.concursolutions.com', + }, + { + label: 'US Gov — no client cert (www-usg.api.concursolutions.com)', + id: 'www-usg.api.concursolutions.com', + }, + { + label: 'GLZ — may request client cert (glz.api.concursolutions.com)', + id: 'glz.api.concursolutions.com', + }, + { + label: 'US Implementation — may request client cert (us-impl.api.concursolutions.com)', + id: 'us-impl.api.concursolutions.com', + }, + { + label: 'US Implementation — no client cert (www-us-impl.api.concursolutions.com)', + id: 'www-us-impl.api.concursolutions.com', + }, + { + label: + 'EMEA Implementation — may request client cert (emea-impl.api.concursolutions.com)', + id: 'emea-impl.api.concursolutions.com', + }, + { + label: 'EMEA Implementation — no client cert (www-emea-impl.api.concursolutions.com)', + id: 'www-emea-impl.api.concursolutions.com', + }, ], value: () => 'us.api.concursolutions.com', required: true, @@ -560,15 +656,14 @@ export const SapConcurBlock: BlockConfig = { id: 'username', title: 'Username', type: 'short-input', - placeholder: 'Username (password grant only)', + placeholder: 'User login — or leave blank and set Company UUID', condition: { field: 'grantType', value: 'password' }, - required: { field: 'grantType', value: 'password' }, }, { id: 'password', title: 'Password', type: 'short-input', - placeholder: 'Password (password grant only)', + placeholder: 'User password, or the 24-hour company request token', password: true, condition: { field: 'grantType', value: 'password' }, required: { field: 'grantType', value: 'password' }, @@ -577,7 +672,7 @@ export const SapConcurBlock: BlockConfig = { id: 'companyUuid', title: 'Company UUID', type: 'short-input', - placeholder: 'Multi-company access token UUID (optional)', + placeholder: 'Company-level auth: sent as the token username', mode: 'advanced', }, @@ -623,8 +718,8 @@ export const SapConcurBlock: BlockConfig = { type: 'dropdown', options: [{ label: 'TRAVELER', id: 'TRAVELER' }], value: () => 'TRAVELER', - condition: { field: 'operation', value: QUICK_EXPENSE_CONTEXT_TYPE_OPS }, - required: { field: 'operation', value: QUICK_EXPENSE_CONTEXT_TYPE_OPS }, + condition: { field: 'operation', value: TRAVELER_ONLY_CONTEXT_TYPE_OPS }, + required: { field: 'operation', value: TRAVELER_ONLY_CONTEXT_TYPE_OPS }, }, { id: 'contextType', @@ -632,11 +727,11 @@ export const SapConcurBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'TRAVELER', id: 'TRAVELER' }, - { label: 'PROXY', id: 'PROXY' }, + { label: 'MANAGER', id: 'MANAGER' }, ], value: () => 'TRAVELER', - condition: { field: 'operation', value: ALLOCATION_CONTEXT_TYPE_OPS }, - required: { field: 'operation', value: ALLOCATION_CONTEXT_TYPE_OPS }, + condition: { field: 'operation', value: LIST_ALLOCATIONS_CONTEXT_TYPE_OPS }, + required: { field: 'operation', value: LIST_ALLOCATIONS_CONTEXT_TYPE_OPS }, }, { id: 'contextType', @@ -647,17 +742,20 @@ export const SapConcurBlock: BlockConfig = { { label: 'PROXY', id: 'PROXY' }, ], value: () => 'TRAVELER', - condition: { field: 'operation', value: ATTENDEE_CONTEXT_TYPE_OPS }, - required: { field: 'operation', value: ATTENDEE_CONTEXT_TYPE_OPS }, + condition: { field: 'operation', value: ALLOCATION_CONTEXT_TYPE_OPS }, + required: { field: 'operation', value: ALLOCATION_CONTEXT_TYPE_OPS }, }, { id: 'contextType', title: 'Context Type', type: 'dropdown', - options: [{ label: 'MANAGER', id: 'MANAGER' }], - value: () => 'MANAGER', - condition: { field: 'operation', value: MANAGER_ONLY_CONTEXT_TYPE_OPS }, - required: { field: 'operation', value: MANAGER_ONLY_CONTEXT_TYPE_OPS }, + options: [ + { label: 'TRAVELER', id: 'TRAVELER' }, + { label: 'PROXY', id: 'PROXY' }, + ], + value: () => 'TRAVELER', + condition: { field: 'operation', value: ATTENDEE_CONTEXT_TYPE_OPS }, + required: { field: 'operation', value: ATTENDEE_CONTEXT_TYPE_OPS }, }, // Report ID @@ -704,6 +802,15 @@ export const SapConcurBlock: BlockConfig = { title: 'Approval Status Code', type: 'short-input', placeholder: 'A_NOTF, A_PEND, A_APPR...', + wandConfig: { + enabled: true, + prompt: `Generate a SAP Concur v3 expense report approval status code from the user's request. + +Valid codes include A_NOTF (not submitted), A_PEND (pending approval), A_APPR (approved), A_ACCO (pending cost object approval), A_RESU (submitted, pending validation), A_TEXP (approved, pending expense processor review), A_RJCT (sent back to employee), A_PECO (pending expense processor review). + +Return ONLY the approval status code - no explanations, no extra text.`, + placeholder: 'Describe the approval state (e.g., "reports still awaiting approval")', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -712,6 +819,15 @@ export const SapConcurBlock: BlockConfig = { title: 'Payment Status Code', type: 'short-input', placeholder: 'P_NOTP, P_PAID...', + wandConfig: { + enabled: true, + prompt: `Generate a SAP Concur v3 expense report payment status code from the user's request. + +Valid codes include P_NOTP (not paid), P_PROC (processing payment), P_PAYC (payment confirmed), P_PAID (paid). + +Return ONLY the payment status code - no explanations, no extra text.`, + placeholder: 'Describe the payment state (e.g., "reports that have not been reimbursed")', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -736,6 +852,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Submit Date After', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into a SAP Concur v3 expense report date filter. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "the first of last month" -> the first day of the previous month, "30 days ago" -> the date 30 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the cutoff date (e.g., "the first of last month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -744,6 +871,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Submit Date Before', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into a SAP Concur v3 expense report date filter. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "the first of last month" -> the first day of the previous month, "30 days ago" -> the date 30 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the cutoff date (e.g., "the first of last month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -752,6 +890,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Paid Date After', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into a SAP Concur v3 expense report date filter. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "the first of last month" -> the first day of the previous month, "30 days ago" -> the date 30 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the cutoff date (e.g., "the first of last month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -760,6 +909,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Paid Date Before', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into a SAP Concur v3 expense report date filter. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "the first of last month" -> the first day of the previous month, "30 days ago" -> the date 30 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the cutoff date (e.g., "the first of last month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -768,6 +928,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Modified Date After', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into a SAP Concur v3 expense report date filter. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "the first of last month" -> the first day of the previous month, "30 days ago" -> the date 30 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the cutoff date (e.g., "the first of last month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -776,6 +947,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Modified Date Before', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into a SAP Concur v3 expense report date filter. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "the first of last month" -> the first day of the previous month, "30 days ago" -> the date 30 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the cutoff date (e.g., "the first of last month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -784,6 +966,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Create Date After', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into a SAP Concur v3 expense report date filter. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "the first of last month" -> the first day of the previous month, "30 days ago" -> the date 30 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the cutoff date (e.g., "the first of last month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -792,6 +985,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Create Date Before', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into a SAP Concur v3 expense report date filter. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "the first of last month" -> the first day of the previous month, "30 days ago" -> the date 30 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the cutoff date (e.g., "the first of last month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_expense_reports' }, mode: 'advanced', }, @@ -810,6 +1014,13 @@ export const SapConcurBlock: BlockConfig = { condition: { field: 'operation', value: 'sap_concur_list_report_comments' }, mode: 'advanced', }, + { + id: 'excludeExpenses', + title: 'Exclude Expense Exceptions', + type: 'switch', + condition: { field: 'operation', value: 'sap_concur_list_exceptions' }, + mode: 'advanced', + }, // Receipt { @@ -905,12 +1116,21 @@ export const SapConcurBlock: BlockConfig = { 'sap_concur_list_travel_requests', 'sap_concur_get_travel_request', 'sap_concur_create_travel_request', + 'sap_concur_update_travel_request', 'sap_concur_delete_travel_request', 'sap_concur_move_travel_request', ], }, mode: 'advanced', }, + { + id: 'companyID', + title: 'Company ID', + type: 'short-input', + placeholder: 'Company identifier for the workflow action', + condition: { field: 'operation', value: 'sap_concur_move_travel_request' }, + mode: 'advanced', + }, { id: 'action', title: 'Workflow Action', @@ -1043,7 +1263,7 @@ export const SapConcurBlock: BlockConfig = { id: 'systemFormat', title: 'System Format', type: 'short-input', - placeholder: 'GDS', + placeholder: 'Tripit', condition: { field: 'operation', value: 'sap_concur_get_itinerary' }, mode: 'advanced', }, @@ -1052,14 +1272,38 @@ export const SapConcurBlock: BlockConfig = { title: 'Start Date', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into the earliest trip start date for a SAP Concur itinerary search. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "the start of this quarter" -> the first day of the current quarter, "two weeks ago" -> the date 14 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the earliest trip date (e.g., "the start of this quarter")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, + mode: 'advanced', }, { id: 'endDate', title: 'End Date', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into the latest trip end date for a SAP Concur itinerary search. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "end of next month" -> the last day of the following month, "today" -> today's date. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the latest trip date (e.g., "end of next month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, + mode: 'advanced', }, { id: 'bookingType', @@ -1075,6 +1319,7 @@ export const SapConcurBlock: BlockConfig = { type: 'short-input', placeholder: '25', condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, + mode: 'advanced', }, { id: 'itineraryPage', @@ -1082,6 +1327,7 @@ export const SapConcurBlock: BlockConfig = { type: 'short-input', placeholder: '1', condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, + mode: 'advanced', }, { id: 'includeMetadata', @@ -1097,11 +1343,37 @@ export const SapConcurBlock: BlockConfig = { condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, mode: 'advanced', }, + { + id: 'includeVirtualTrip', + title: 'Include Virtual Trips', + type: 'short-input', + placeholder: '1 to include Request-booked offline segments', + condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, + mode: 'advanced', + }, + { + id: 'includeGuestBookings', + title: 'Include Guest Bookings', + type: 'switch', + condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, + mode: 'advanced', + }, { id: 'createdAfterDate', title: 'Created After Date', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into the earliest trip creation date for a SAP Concur itinerary search. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "booked since last Monday" -> the date of the most recent Monday. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe when the trip was booked (e.g., "since last Monday")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, mode: 'advanced', }, @@ -1110,6 +1382,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Created Before Date', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into the latest trip creation date for a SAP Concur itinerary search. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "booked before this month" -> the last day of the previous month. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the booking cutoff (e.g., "before this month")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, mode: 'advanced', }, @@ -1118,6 +1401,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Last Modified Date', type: 'short-input', placeholder: 'YYYY-MM-DD', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a date into the last-modified cutoff for a SAP Concur itinerary search. + +The output must be a calendar date in YYYY-MM-DD form, resolved against the current date. For example "changed in the last week" -> the date 7 days before today. + +If the input looks like a reference to another block's output (contains < and >) or is already YYYY-MM-DD, return it as-is. +Return ONLY the YYYY-MM-DD date - no explanations, no extra text.`, + placeholder: 'Describe the change cutoff (e.g., "changed in the last week")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_itineraries' }, mode: 'advanced', }, @@ -1144,6 +1438,7 @@ export const SapConcurBlock: BlockConfig = { type: 'short-input', placeholder: '100', condition: { field: 'operation', value: 'sap_concur_list_users' }, + mode: 'advanced', }, { id: 'usersCursor', @@ -1151,12 +1446,22 @@ export const SapConcurBlock: BlockConfig = { type: 'short-input', placeholder: 'Pagination cursor from previous response', condition: { field: 'operation', value: 'sap_concur_list_users' }, + mode: 'advanced', }, { id: 'attributes', title: 'Attributes', type: 'short-input', placeholder: 'id,active,emails', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of SCIM user attribute names to return from SAP Concur Identity v4. + +Use SCIM attribute paths such as id, externalId, userName, active, displayName, name.givenName, name.familyName, emails, emails.value, title, userType, and enterprise extension paths like urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber. + +Return ONLY the comma-separated attribute names - no explanations, no extra text.`, + placeholder: 'Describe the user fields to return (e.g., "just email and status")', + }, condition: { field: 'operation', value: ['sap_concur_list_users', 'sap_concur_get_user'], @@ -1167,6 +1472,15 @@ export const SapConcurBlock: BlockConfig = { title: 'Excluded Attributes', type: 'short-input', placeholder: 'name,emails', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of SCIM user attribute names to omit from a SAP Concur Identity v4 response. + +Use SCIM attribute paths such as name, emails, phoneNumbers, addresses, entitlements, and enterprise extension paths like urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager. + +Return ONLY the comma-separated attribute names - no explanations, no extra text.`, + placeholder: 'Describe the user fields to drop (e.g., "leave out addresses and phones")', + }, condition: { field: 'operation', value: ['sap_concur_list_users', 'sap_concur_get_user'], @@ -1203,6 +1517,7 @@ export const SapConcurBlock: BlockConfig = { title: 'Admin View', type: 'switch', condition: { field: 'operation', value: 'sap_concur_list_budgets' }, + mode: 'advanced', }, { id: 'responseSchema', @@ -1214,6 +1529,7 @@ export const SapConcurBlock: BlockConfig = { ], value: () => '', condition: { field: 'operation', value: 'sap_concur_list_budgets' }, + mode: 'advanced', }, // Purchase Requests @@ -1236,6 +1552,7 @@ export const SapConcurBlock: BlockConfig = { field: 'operation', value: ['sap_concur_list_expense_reports', 'sap_concur_list_travel_requests'], }, + mode: 'advanced', }, { id: 'offset', @@ -1246,6 +1563,7 @@ export const SapConcurBlock: BlockConfig = { field: 'operation', value: ['sap_concur_list_budgets', 'sap_concur_list_expense_reports'], }, + mode: 'advanced', }, { id: 'page', @@ -1256,16 +1574,33 @@ export const SapConcurBlock: BlockConfig = { field: 'operation', value: ['sap_concur_list_lists', 'sap_concur_list_list_items'], }, + mode: 'advanced', }, { id: 'sortBy', title: 'Sort By', - type: 'short-input', - placeholder: 'name', - condition: { - field: 'operation', - value: ['sap_concur_list_lists', 'sap_concur_list_list_items'], - }, + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Name', id: 'name' }, + { label: 'Level Count', id: 'levelcount' }, + { label: 'List Category', id: 'listcategory' }, + ], + value: () => '', + condition: { field: 'operation', value: 'sap_concur_list_lists' }, + mode: 'advanced', + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Value', id: 'value' }, + { label: 'Short Code', id: 'shortCode' }, + ], + value: () => '', + condition: { field: 'operation', value: 'sap_concur_list_list_items' }, mode: 'advanced', }, { @@ -1317,6 +1652,70 @@ export const SapConcurBlock: BlockConfig = { field: 'operation', value: ['sap_concur_list_travel_requests'], }, + mode: 'advanced', + }, + + // Custom list / list item filters (v4) + { + id: 'value', + title: 'Value', + type: 'short-input', + placeholder: 'Exact value, or an operator form like sw:Trav, ew:ing, not:Old, cp:cost', + condition: { + field: 'operation', + value: ['sap_concur_list_lists', 'sap_concur_list_list_items'], + }, + mode: 'advanced', + }, + { + id: 'categoryType', + title: 'Category Type', + type: 'short-input', + placeholder: 'List category type (e.g., EXPENSE)', + condition: { field: 'operation', value: 'sap_concur_list_lists' }, + mode: 'advanced', + }, + { + id: 'levelCount', + title: 'Level Count', + type: 'short-input', + placeholder: 'Exact count, or an operator form like eq:2, gt:1, gte:2, lt:4, lte:3', + condition: { field: 'operation', value: 'sap_concur_list_lists' }, + mode: 'advanced', + }, + { + id: 'isDeleted', + title: 'Is Deleted', + type: 'short-input', + placeholder: 'true, false, or the operator form eq:true', + condition: { + field: 'operation', + value: ['sap_concur_list_lists', 'sap_concur_list_list_items'], + }, + mode: 'advanced', + }, + { + id: 'shortCode', + title: 'Short Code', + type: 'short-input', + placeholder: 'Exact short code, or an operator form like sw:EU', + condition: { field: 'operation', value: 'sap_concur_list_list_items' }, + mode: 'advanced', + }, + { + id: 'shortCodeOrValue', + title: 'Short Code Or Value', + type: 'short-input', + placeholder: 'Matches either field, or an operator form like cp:travel', + condition: { field: 'operation', value: 'sap_concur_list_list_items' }, + mode: 'advanced', + }, + { + id: 'hasChildren', + title: 'Has Children', + type: 'switch', + condition: { field: 'operation', value: 'sap_concur_list_list_items' }, + mode: 'advanced', }, // List Item ID (for update/delete list item) @@ -1335,6 +1734,17 @@ export const SapConcurBlock: BlockConfig = { title: 'Last Modified Date', type: 'short-input', placeholder: '1900-01-01T00:00:00 (UTC datetime)', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a moment in time into a SAP Concur Travel Profile summary cutoff. + +The output must be a UTC datetime with no timezone suffix, in YYYY-MM-DDTHH:mm:ss form, resolved against the current date. For example "everything" -> 1900-01-01T00:00:00, "changed in the last day" -> the datetime 24 hours before now. + +If the input looks like a reference to another block's output (contains < and >) or is already in YYYY-MM-DDTHH:mm:ss form, return it as-is. +Return ONLY the UTC datetime - no explanations, no extra text.`, + placeholder: 'Describe the cutoff (e.g., "profiles changed in the last day")', + generationType: 'timestamp', + }, condition: { field: 'operation', value: 'sap_concur_list_travel_profiles_summary', @@ -1350,6 +1760,7 @@ export const SapConcurBlock: BlockConfig = { type: 'short-input', placeholder: '1', condition: { field: 'operation', value: 'sap_concur_list_travel_profiles_summary' }, + mode: 'advanced', }, { id: 'itemsPerPage', @@ -1357,13 +1768,38 @@ export const SapConcurBlock: BlockConfig = { type: 'short-input', placeholder: '200', condition: { field: 'operation', value: 'sap_concur_list_travel_profiles_summary' }, + mode: 'advanced', }, { id: 'travelConfigs', title: 'Travel Config IDs', type: 'short-input', placeholder: 'Comma-separated config ids', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of SAP Concur travel configuration IDs from the user's request. + +Travel configuration IDs are numeric identifiers issued by Concur. Emit them separated by commas with no spaces. + +If the input looks like a reference to another block's output (contains < and >), return it as-is. +Return ONLY the comma-separated travel config IDs - no explanations, no extra text.`, + placeholder: 'Describe or paste the travel configurations to scope the search to', + }, condition: { field: 'operation', value: 'sap_concur_list_travel_profiles_summary' }, + mode: 'advanced', + }, + { + id: 'active', + title: 'User State', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Active users', id: '1' }, + { label: 'Inactive users', id: '0' }, + ], + value: () => '', + condition: { field: 'operation', value: 'sap_concur_list_travel_profiles_summary' }, + mode: 'advanced', }, // Locations fields (v5) @@ -1380,6 +1816,7 @@ export const SapConcurBlock: BlockConfig = { type: 'short-input', placeholder: 'IATA / city code (e.g., SEA)', condition: { field: 'operation', value: 'sap_concur_search_locations' }, + mode: 'advanced', }, { id: 'locationNameId', @@ -1403,6 +1840,7 @@ export const SapConcurBlock: BlockConfig = { type: 'short-input', placeholder: 'US', condition: { field: 'operation', value: 'sap_concur_search_locations' }, + mode: 'advanced', }, { id: 'subdivisionCode', @@ -1410,6 +1848,7 @@ export const SapConcurBlock: BlockConfig = { type: 'short-input', placeholder: 'US-WA', condition: { field: 'operation', value: 'sap_concur_search_locations' }, + mode: 'advanced', }, { id: 'adminRegionId', @@ -1430,7 +1869,7 @@ export const SapConcurBlock: BlockConfig = { condition: { field: 'operation', value: RECEIPT_UPLOAD_OPS }, mode: 'basic', multiple: false, - required: true, + required: { field: 'operation', value: RECEIPT_UPLOAD_OPS }, acceptedTypes: 'image/jpeg,image/png,image/gif,image/tiff,application/pdf', }, // Receipt Image (advanced mode — variable reference) @@ -1442,30 +1881,43 @@ export const SapConcurBlock: BlockConfig = { placeholder: 'Reference file from previous block', condition: { field: 'operation', value: RECEIPT_UPLOAD_OPS }, mode: 'advanced', - required: true, + required: { field: 'operation', value: RECEIPT_UPLOAD_OPS }, }, - { - id: 'forwardId', - title: 'Forward ID', - type: 'short-input', - placeholder: 'Optional dedup id (max 40 chars)', - condition: { field: 'operation', value: 'sap_concur_upload_receipt_image' }, - mode: 'advanced', - }, - // Body (JSON payload) — shared across all create/update/action ops { id: 'body', title: 'Request Body (JSON)', type: 'long-input', placeholder: '{ ... }', + wandConfig: { + enabled: true, + prompt: `Generate the JSON request body for the selected SAP Concur operation from the user's request. + +Match the payload to the resource being written. Every family below is camelCase. + +Expense reports (v4): name, businessPurpose, comment, policyId, countryCode, countrySubDivisionCode, reportDate, startDate, endDate, and reportSource — reportSource is REQUIRED when updating a report and must be one of EA, MOB, OTHER, SE, TR, UI. + +Quick expenses (v4): expenseTypeId (required), transactionAmount as { currencyCode, value } (required), transactionDate as YYYY-MM-DD (required), plus optional comment, vendor, paymentTypeId (CASHX, CPAID or PENDC) and location as { city, countryCode, countrySubDivisionCode }. + +Travel requests and expected expenses (Request v4): name, businessPurpose, startDate, endDate, startTime, endTime, policy as { id }, mainDestination as { city, countryCode, countrySubDivisionCode }, expenseType, transactionDate, and custom1 through custom20. Amounts here are { value, currency } — this family uses currency, NOT currencyCode. Never send an id field on create. + +SCIM users (Identity v4.1): create and update payloads use schemas, userName, name.givenName, name.familyName, emails, active, and companyId inside urn:ietf:params:scim:schemas:extension:enterprise:2.0:User. Update uses urn:ietf:params:scim:api:messages:2.0:PatchOp with Operations. Search payloads use schemas with urn:ietf:params:scim:api:messages:concur:2.0:SearchRequest plus filter, count, attributes and cursor — startIndex is NOT supported as a request parameter. + +List items: listId, level, value, shortCode. Cash advances: amountRequested as { currency, amount }, name and userId (all required), plus optional accountCode, comment and purpose. + +Omit fields the user did not describe rather than inventing identifiers. + +Return ONLY the JSON object - no explanations, no extra text.`, + placeholder: + 'Describe the payload in plain language (e.g., "a $42 taxi in USD on March 3")', + generationType: 'json-object', + }, condition: { field: 'operation', value: BODY_OPS }, required: { field: 'operation', value: [ 'sap_concur_create_expense_report', 'sap_concur_update_expense_report', - 'sap_concur_approve_expense_report', 'sap_concur_send_back_expense_report', 'sap_concur_update_expense', 'sap_concur_update_allocation', @@ -1578,15 +2030,14 @@ export const SapConcurBlock: BlockConfig = { const offset = params.offset ? Number(params.offset) : undefined const start = params.start ? Number(params.start) : undefined const count = params.count ? Number(params.count) : undefined - const startIndex = params.startIndex ? Number(params.startIndex) : undefined const page = params.page ? Number(params.page) : undefined - const levelCount = params.levelCount ? Number(params.levelCount) : undefined + const contextType = clampContextType(params.operation, params.contextType) switch (params.operation) { case 'sap_concur_list_expense_reports': return { ...auth, - user: params.expenseReportUser || params.userId || undefined, + user: params.expenseReportUser || undefined, submitDateBefore: params.submitDateBefore || undefined, submitDateAfter: params.submitDateAfter || undefined, paidDateBefore: params.paidDateBefore || undefined, @@ -1606,21 +2057,21 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, } case 'sap_concur_create_expense_report': return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, body: params.body, } case 'sap_concur_update_expense_report': return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, body: params.body, } @@ -1631,15 +2082,13 @@ export const SapConcurBlock: BlockConfig = { ...auth, userId: params.userId, reportId: params.reportId, - body: params.body || undefined, } case 'sap_concur_recall_expense_report': return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, - body: params.body || undefined, } case 'sap_concur_approve_expense_report': case 'sap_concur_send_back_expense_report': @@ -1652,7 +2101,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType: 'MANAGER', sort: params.reportsToApproveSort || undefined, order: params.reportsToApproveOrder || undefined, includeDelegateApprovals: toBool(params.includeDelegateApprovals), @@ -1661,7 +2110,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, } case 'sap_concur_get_expense': @@ -1669,7 +2118,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, expenseId: params.expenseId, } @@ -1690,7 +2139,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, expenseId: params.expenseId, } @@ -1698,7 +2147,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, allocationId: params.allocationId, } @@ -1706,7 +2155,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, allocationId: params.allocationId, body: params.body, @@ -1715,7 +2164,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, expenseId: params.expenseId, } @@ -1723,7 +2172,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, expenseId: params.expenseId, body: params.body, @@ -1732,7 +2181,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, expenseId: params.expenseId, } @@ -1740,7 +2189,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, includeAllComments: toBool(params.includeAllComments), } @@ -1748,7 +2197,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, comment: params.comment, } @@ -1756,14 +2205,15 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, reportId: params.reportId, + excludeExpenses: toBool(params.excludeExpenses), } case 'sap_concur_create_quick_expense': return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, body: params.body, } case 'sap_concur_list_receipts': @@ -1798,7 +2248,12 @@ export const SapConcurBlock: BlockConfig = { case 'sap_concur_create_travel_request': return { ...auth, body: params.body, userId: params.travelRequestUserId || undefined } case 'sap_concur_update_travel_request': - return { ...auth, requestUuid: params.requestUuid, body: params.body } + return { + ...auth, + requestUuid: params.requestUuid, + body: params.body, + userId: params.travelRequestUserId || undefined, + } case 'sap_concur_move_travel_request': return { ...auth, @@ -1806,6 +2261,7 @@ export const SapConcurBlock: BlockConfig = { action: params.action, body: params.body || undefined, userId: params.travelRequestUserId || undefined, + companyID: params.companyID || undefined, } case 'sap_concur_list_travel_request_comments': return { ...auth, requestUuid: params.requestUuid } @@ -1862,6 +2318,8 @@ export const SapConcurBlock: BlockConfig = { page: params.itineraryPage ? Number(params.itineraryPage) : undefined, includeMetadata: toBool(params.includeMetadata), includeCanceledTrips: toBool(params.includeCanceledTrips), + includeVirtualTrip: params.includeVirtualTrip || undefined, + includeGuestBookings: toBool(params.includeGuestBookings), createdAfterDate: params.createdAfterDate || undefined, createdBeforeDate: params.createdBeforeDate || undefined, lastModifiedDate: params.itineraryLastModifiedDate || undefined, @@ -1901,12 +2359,12 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, page, - sortBy: params.sortBy || undefined, + sortBy: clampSortBy(params.operation, params.sortBy), sortDirection: params.sortDirection || undefined, value: params.value || undefined, categoryType: params.categoryType || undefined, - isDeleted: toBool(params.isDeleted), - levelCount, + isDeleted: params.isDeleted || undefined, + levelCount: params.levelCount || undefined, } case 'sap_concur_get_list': return { ...auth, listId: params.listId } @@ -1915,10 +2373,10 @@ export const SapConcurBlock: BlockConfig = { ...auth, listId: params.listId, page, - sortBy: params.sortBy || undefined, + sortBy: clampSortBy(params.operation, params.sortBy), sortDirection: params.sortDirection || undefined, hasChildren: toBool(params.hasChildren), - isDeleted: toBool(params.isDeleted), + isDeleted: params.isDeleted || undefined, shortCode: params.shortCode || undefined, value: params.value || undefined, shortCodeOrValue: params.shortCodeOrValue || undefined, @@ -1964,6 +2422,7 @@ export const SapConcurBlock: BlockConfig = { page: params.travelProfilePage ? Number(params.travelProfilePage) : undefined, itemsPerPage: params.itemsPerPage ? Number(params.itemsPerPage) : undefined, travelConfigs: params.travelConfigs || undefined, + active: params.active || undefined, } case 'sap_concur_search_locations': return { @@ -1984,7 +2443,6 @@ export const SapConcurBlock: BlockConfig = { ...auth, userId: params.userId, receipt: normalizedReceipt, - forwardId: params.forwardId || undefined, } } case 'sap_concur_create_quick_expense_with_image': { @@ -1994,7 +2452,7 @@ export const SapConcurBlock: BlockConfig = { return { ...auth, userId: params.userId, - contextType: params.contextType, + contextType, receipt: normalizedReceipt, body: params.body, } @@ -2017,7 +2475,8 @@ export const SapConcurBlock: BlockConfig = { userId: { type: 'string', description: 'Concur user UUID' }, contextType: { type: 'string', - description: 'Access context (TRAVELER/MANAGER, or TRAVELER/PROXY for allocations)', + description: + 'Access context, clamped per operation to the values that operation accepts (TRAVELER/MANAGER/PROCESSOR/PROXY for get expense report, TRAVELER/MANAGER/PROXY for get expense, exceptions and report comments, TRAVELER/MANAGER for list allocations, TRAVELER/PROXY for single allocations, attendees and report create/update/recall, TRAVELER only for list expenses, itemizations and quick expenses)', }, reportId: { type: 'string', description: 'Expense report ID' }, expenseId: { type: 'string', description: 'Expense entry ID' }, @@ -2065,7 +2524,13 @@ export const SapConcurBlock: BlockConfig = { view: { type: 'string', description: 'Travel request view filter' }, travelRequestUserId: { type: 'string', - description: 'User UUID for travel request impersonation/filter', + description: + "Optional user UUID for travel request impersonation/filter. On Update Travel Request it is taken into account only when calling with a Company token; if not provided the update is performed as 'Concur System'", + }, + companyID: { + type: 'string', + description: + 'Optional company identifier for a travel request workflow action (documented as companyID, distinct from companyUuid)', }, travelRequestApprovedBefore: { type: 'string', description: 'Travel requests approved before' }, travelRequestApprovedAfter: { type: 'string', description: 'Travel requests approved after' }, @@ -2085,11 +2550,22 @@ export const SapConcurBlock: BlockConfig = { startDate: { type: 'string', description: 'Itinerary start date filter' }, endDate: { type: 'string', description: 'Itinerary end date filter' }, bookingType: { type: 'string', description: 'Itinerary booking type filter' }, - systemFormat: { type: 'string', description: 'Itinerary system format (e.g., GDS)' }, + systemFormat: { + type: 'string', + description: 'Itinerary system format — the only supported value is Tripit', + }, itineraryItemsPerPage: { type: 'number', description: 'Itinerary items per page' }, itineraryPage: { type: 'number', description: 'Itinerary page number' }, includeMetadata: { type: 'boolean', description: 'Include itinerary paging metadata' }, includeCanceledTrips: { type: 'boolean', description: 'Include canceled trips' }, + includeVirtualTrip: { + type: 'string', + description: 'Set to 1 to include virtual trips carrying Request-booked offline segments', + }, + includeGuestBookings: { + type: 'boolean', + description: 'Include trips booked on behalf of guests (defaults to false)', + }, createdAfterDate: { type: 'string', description: 'Itinerary created-after date' }, createdBeforeDate: { type: 'string', description: 'Itinerary created-before date' }, itineraryLastModifiedDate: { type: 'string', description: 'Itinerary last-modified date' }, @@ -2104,8 +2580,14 @@ export const SapConcurBlock: BlockConfig = { sortDirection: { type: 'string', description: 'Sort direction: asc or desc' }, value: { type: 'string', description: 'Filter by value/name for v4 lists/items endpoints' }, categoryType: { type: 'string', description: 'List category.type filter' }, - isDeleted: { type: 'boolean', description: 'Include deleted lists/items' }, - levelCount: { type: 'number', description: 'Filter lists by level count' }, + isDeleted: { + type: 'string', + description: 'Include deleted lists/items — accepts true, false, or an operator form', + }, + levelCount: { + type: 'string', + description: 'Filter lists by level count — accepts an exact count or eq:/gt:/gte:/lt:/lte:', + }, hasChildren: { type: 'boolean', description: 'Filter list items that have children' }, shortCode: { type: 'string', description: 'Filter list items by short code' }, shortCodeOrValue: { type: 'string', description: 'Filter list items by short code or value' }, @@ -2124,6 +2606,10 @@ export const SapConcurBlock: BlockConfig = { travelProfilePage: { type: 'number', description: 'Profile summary page number' }, itemsPerPage: { type: 'number', description: 'Profile summary items per page' }, travelConfigs: { type: 'string', description: 'Comma-separated travel config ids' }, + active: { + type: 'string', + description: 'Travel profile summary user state filter — 1 for active, 0 for inactive', + }, searchText: { type: 'string', description: 'Locations v5 free-text search' }, locCode: { type: 'string', description: 'Locations v5 location code' }, locationNameId: { type: 'string', description: 'Locations v5 location name id' }, @@ -2132,7 +2618,6 @@ export const SapConcurBlock: BlockConfig = { subdivisionCode: { type: 'string', description: 'Locations v5 ISO 3166-2 subdivision code' }, adminRegionId: { type: 'string', description: 'Locations v5 administrative region id' }, receipt: { type: 'json', description: 'Receipt image file (canonical param)' }, - forwardId: { type: 'string', description: 'Optional dedup id for receipt upload' }, reportsToApproveSort: { type: 'string', description: 'Sort field for reportsToApprove (e.g., reportDate)', @@ -2146,16 +2631,25 @@ export const SapConcurBlock: BlockConfig = { type: 'boolean', description: 'Include comments from all expenses in the report', }, + excludeExpenses: { + type: 'boolean', + description: + 'Return only report-header exceptions, excluding expense-level and allocation-level ones', + }, }, outputs: { success: { type: 'boolean', description: 'Whether the operation succeeded' }, status: { type: 'number', description: 'HTTP status code returned by Concur' }, - data: { type: 'json', description: 'Concur API response payload' }, + data: { + type: 'json', + description: + 'Concur API response payload. Shape follows the operation: expense report headers (reportId, name, ownerName, approvalStatusName, paymentStatusName, reportTotal, currencyCode, submitDate); expense entries (expenseId, expenseTypeName, transactionDate, transactionAmount, vendorDescription, isPersonal); itemizations and allocations (allocationId, percentage, amount, custom fields); attendee associations (attendeeId, associatedAmount); report comments (author, text, isLatest); policy exceptions (code, level, message); quick expenses and receipts (quickExpenseIdUri, receiptId, imageId, status); travel requests (requestId, requestUuid, name, approvalStatus, totalApprovedAmount, startDate, endDate) and expected expenses (expenseUuid, expenseType, transactionAmount); cash advances (cashAdvanceId, amount, currencyCode, status); itineraries, which come back as a raw Concur XML string rather than JSON; SCIM user resources (id, userName, displayName, emails, active, meta); custom lists and list items (listId, itemId, level, value, shortCode); budget item headers and categories (budgetId, name, spent, remaining); and locations (locationNameId, locCode, countryCode, subdivisionCode).', + }, }, } export const SapConcurBlockMeta = { - tags: ['automation'], + tags: ['automation', 'payments'], url: 'https://www.concur.com', templates: [ { @@ -2245,7 +2739,7 @@ export const SapConcurBlockMeta = { description: 'Create a quick expense in SAP Concur from a receipt, attaching the receipt image.', content: - '# Capture Quick Expense\n\nLog an out-of-pocket expense quickly, with the receipt attached.\n\n## Steps\n1. If you have a receipt image, run Upload Receipt Image and keep the returned receipt ID, or use Create Quick Expense (With Image) to do both in one step.\n2. Run Create Quick Expense with the vendor, amount, currency, and transaction date.\n3. Verify the entry with Get Expense.\n\n## Output\nReport the created quick expense ID, the captured vendor and amount, and confirm the receipt image is attached.', + '# Capture Quick Expense\n\nLog an out-of-pocket expense quickly, with the receipt attached.\n\n## Steps\n1. If you have a receipt image, use Create Quick Expense (With Image) to upload it and create the expense in one step. To upload on its own, run Upload Receipt Image — it returns no receipt ID, only a `location` URL and a raw `link` header of the form `; rel="processing-status"`. Parse the id out of that URL.\n2. Run Create Quick Expense with the vendor, amount, currency, and transaction date.\n3. To check the upload, run Get Receipt Status with the id from step 1 — that is the only working post-upload read. Do not use List Receipts or Get Receipt: they read the e-receipt family, while Upload Receipt Image writes to the disjoint image-only family, so a freshly uploaded image never appears in List Receipts and Get Receipt on its id returns 404. Reading back the image-only receipt itself requires endpoints this integration does not yet wrap.\n4. A quick expense is not attached to a report yet, so Get Expense cannot read it. Once it has been moved onto a report, List Expenses on that report ID shows the entry.\n\n## Output\nReport the created quick expense ID, the captured vendor and amount, and the receipt processing status if an image was uploaded.', }, { name: 'manage-travel-requests', @@ -2254,5 +2748,33 @@ export const SapConcurBlockMeta = { content: '# Manage Travel Requests\n\nHandle pre-trip travel requests through their approval lifecycle.\n\n## Steps\n1. Run List Travel Requests to find pending requests, then Get Travel Request for full detail on a specific one.\n2. Review the expected expenses and any linked cash advance via Get Request Cash Advance.\n3. Run Move Travel Request (Workflow Action) to advance, approve, or send back the request based on the decision.\n\n## Output\nReturn the travel request ID, destination, estimated cost, and the workflow action applied so the trip approval state is clear.', }, + { + name: 'provision-concur-user-identity', + description: + 'Create, update, search, and deactivate SAP Concur user identities through the SCIM Identity API.', + content: + '# Provision Concur User Identity\n\nRun the joiner, mover, and leaver steps against the SAP Concur Identity (SCIM) API.\n\n## Steps\n1. Run Search Users with a SCIM search body (filter on userName or emails.value) to check whether the person already exists, or List Users with Attributes set to a narrow field list when scanning the directory.\n2. To onboard, run Create User with the SCIM body — schemas, userName, name.givenName, name.familyName, emails, and active.\n3. To change a role, department, or manager, run Update User (PATCH) against the user UUID, then confirm with Get User.\n4. To offboard, prefer Update User (PATCH) setting active to false; use Delete User only when the identity must be removed outright.\n\n## Output\nReport the user UUID, userName, the change applied, and the resulting active state so the identity lifecycle is auditable.', + }, + { + name: 'maintain-concur-custom-lists', + description: + 'Browse and edit SAP Concur custom lists and their items — the value sets behind list-type expense fields.', + content: + '# Maintain Concur Custom Lists\n\nKeep the value sets behind list-type expense and request fields current.\n\n## Steps\n1. Run List Lists to find the list you need, filtering by Value or Category Type; note that on List Lists the Value and Level Count filters accept operator prefixes (sw:, ew:, not:, cp: on Value; eq:, gt:, gte:, lt:, lte: on Level Count), while Is Deleted accepts only eq:.\n2. Run Get List for the definition, then List List Items with the list ID to page through its current entries — there Value, Short Code, and Short Code Or Value take the same string operator prefixes.\n3. Run Create List Item to add a value, Update List Item to correct one, and Delete List Item to retire one. Read a single entry back with Get List Item.\n\n## Output\nReturn the list ID and name, the items added, changed, or retired with their short codes and values, and the level each item sits at.', + }, + { + name: 'track-budget-consumption', + description: + 'Read SAP Concur budget item headers and categories to see how much of each budget is already consumed.', + content: + '# Track Budget Consumption\n\nCheck spend against the budgets configured in SAP Concur.\n\n## Steps\n1. Run List Budget Categories to learn how budgets are grouped for the company.\n2. Run List Budgets to page through budget item headers; enable Admin View to see every budget the credentials can administer, and set Response Schema to COMPACT for a lighter payload.\n3. Run Get Budget on any header of interest for the full detail, including the spent and remaining amounts.\n\n## Output\nReturn each budget header ID, its name and category, the budgeted amount, the amount consumed, and the remaining balance, calling out any budget already over its limit.', + }, + { + name: 'issue-cash-advance', + description: + 'Create, inspect, and issue SAP Concur cash advances, including advances attached to a travel request.', + content: + '# Issue Cash Advance\n\nMove a cash advance from request through to issued funds.\n\n## Steps\n1. Run Create Cash Advance with the amount, currency code, and comment describing the need.\n2. Run Get Cash Advance on the returned ID to confirm the amount and current status, or Get Request Cash Advance when the advance hangs off a travel request UUID.\n3. Once approved, run Issue Cash Advance to record the disbursement, supplying the issued amount and exchange rate in the body when they differ from the request.\n\n## Output\nReturn the cash advance ID, requested and issued amounts with currency, the current status, and the linked travel request UUID when there is one.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 1b9149c8835..86b060fa685 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -17946,7 +17946,7 @@ "slug": "sap-concur", "name": "SAP Concur", "description": "Manage expense reports, travel requests, cash advances, and more in SAP Concur", - "longDescription": "Connect SAP Concur via OAuth 2.0. Manage expense reports and line items, allocations, attendees, comments, exceptions, quick expenses, receipts, travel requests and expected expenses, cash advances, itineraries, user identities, custom lists, budgets, exchange rates, and purchase requests across every Concur datacenter.", + "longDescription": "Connect SAP Concur with an OAuth client ID and secret (client-credentials or password grant) — no account linking required. Manage expense reports and line items, allocations, attendees, comments, exceptions, quick expenses, receipts, travel requests and expected expenses, cash advances, itineraries, user identities, custom lists, budgets, exchange rates, and purchase requests across every Concur datacenter.", "bgColor": "#FFFFFF", "iconName": "SapConcurIcon", "docsUrl": "https://docs.sim.ai/integrations/sap_concur", @@ -17965,7 +17965,7 @@ }, { "name": "Update Expense Report", - "description": "Update an unsubmitted expense report (PATCH /expensereports/v4/users/{userId}/context/{contextType}/reports/{reportId} — supported contexts: TRAVELER, PROXY). Body fields: businessPurpose, comment, customData, name, etc." + "description": "Update an unsubmitted expense report (PATCH /expensereports/v4/users/{userId}/context/{contextType}/reports/{reportId} — supported contexts: TRAVELER, PROXY). The body must always include `reportSource` (EA, MOB, OTHER, SE, TR, or UI)." }, { "name": "Delete Expense Report", @@ -17973,15 +17973,15 @@ }, { "name": "Submit Expense Report", - "description": "Submit an expense report into the workflow via Expense Report v4 (PATCH /expensereports/v4/users/{userId}/reports/{reportId}/submit)." + "description": "Submit an expense report into the workflow via Expense Report v4 (PATCH /expensereports/v4/users/{userId}/reports/{reportId}/submit). Takes no request body." }, { "name": "Recall Expense Report", - "description": "Recall a submitted expense report (PATCH /expensereports/v4/users/{userId}/context/{contextType}/reports/{reportId}/recall — supported contexts: TRAVELER, PROXY). No request body is required." + "description": "Recall a submitted expense report (PATCH /expensereports/v4/users/{userId}/context/{contextType}/reports/{reportId}/recall — supported contexts: TRAVELER, PROXY). Takes no request body. This operation supports user-level access tokens: set grantType to \"password\" with username and password, since the default client_credentials grant yields a company-level token." }, { "name": "Approve Expense Report", - "description": "Approve an expense report as a manager (PATCH /expensereports/v4/reports/{reportId}/approve). Required body field: comment." + "description": "Approve an expense report as a manager (PATCH /expensereports/v4/reports/{reportId}/approve). Optional body fields: comment, expenseRejectedComment (required if the report has rejected expenses), expectedStepCode, expectedStepSequence, statusId (default A_APPR)." }, { "name": "Send Back Expense Report", @@ -18001,7 +18001,7 @@ }, { "name": "Update Expense", - "description": "Update an expense (PATCH /expensereports/v4/reports/{reportId}/expenses/{expenseId})." + "description": "Update an expense (PATCH /expensereports/v4/reports/{reportId}/expenses/{expenseId}). Only Company JWT authentication is allowed on this endpoint — the password grant is rejected. A submitted report cannot be updated once it has reached a Paid workflow status. Although the primary intent of this operation is for submitted report updates, it also works on unsubmitted reports, but with the same limited set of fields." }, { "name": "Delete Expense", @@ -18049,7 +18049,7 @@ }, { "name": "Create Quick Expense", - "description": "Create a quick expense (POST /quickexpense/v4/users/{userId}/context/TRAVELER/quickexpenses)." + "description": "Create a quick expense (POST /quickexpense/v4/users/{userId}/context/{contextType}/quickexpenses). TRAVELER is the only supported context type." }, { "name": "Create Quick Expense (With Image)", @@ -18057,7 +18057,7 @@ }, { "name": "List Receipts", - "description": "List receipts for a user (GET /receipts/v4/users/{userId})." + "description": "List receipts for a user (GET /receipts/v4/users/{userId}). Concur documents no query parameters for this endpoint, so page size and offset cannot be controlled; follow the \"next\" URL in the response to page forward." }, { "name": "Get Receipt", @@ -18101,7 +18101,7 @@ }, { "name": "Get Request Cash Advance", - "description": "Get a single cash advance assigned to a travel request (GET /travelrequest/v4/cashadvances/{cashAdvanceUuid})." + "description": "Get a single cash advance assigned to a travel request (GET /travelrequest/v4/cashadvances/{cashAdvanceUuid}). This endpoint exists for feature parity only and will be deprecated in the future — SAP recommends relying on the list of cash advances link available in the Request payload response instead." }, { "name": "Create Expected Expense", @@ -18161,7 +18161,7 @@ }, { "name": "Delete User", - "description": "Delete a user identity (DELETE /profile/identity/v4.1/Users/{id})." + "description": "Hard delete a user identity (DELETE /profile/identity/v4.1/Users/{id}). Not recommended: SAP restricts hard delete to users with no transaction history and governs it by the Concur Data Retention policy. To deactivate a user instead, use SAP Concur Update User with a PATCH replacing active with false." }, { "name": "Search Users", @@ -18193,7 +18193,7 @@ }, { "name": "Delete List Item", - "description": "Delete a list item (DELETE /list/v4/items/{itemId})." + "description": "Delete a list item from all lists that contain it (DELETE /list/v4/items/{itemId}). This is not scoped to a single list, and all children of that list item are also deleted." }, { "name": "List Budgets", @@ -18238,7 +18238,7 @@ "authType": "api-key", "category": "tools", "integrationType": "productivity", - "tags": ["automation"] + "tags": ["automation", "payments"] }, { "type": "sap_s4hana", diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index d5fe7ccc760..62aa01aa471 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -283,6 +283,15 @@ export const SUBBLOCK_ID_MIGRATIONS: 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)"}}},"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)"}}},"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)"}}},"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)"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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)"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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)"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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)"}}},"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"}}},"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"}}},"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"}}},"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)"}}},"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)"}}},"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)"}}},"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)"}}},"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)"}}},"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)"}}},"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"}}},"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"}}},"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."}}},"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"}}},"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."}}},"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"}}},"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"}}},"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"}}},"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\\"})"}}},"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"}}},"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\\")"}}},"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"}}},"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."}}},"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"}}},"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"}}},"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)"}}},"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)"}}},"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\\")"}}},"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\\")"}}},"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."}}},"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."}}},"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\\"})"}}},"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."}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}}},"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"}}},"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)"}}},"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"}}},"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"}}},"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."}}},"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"}}},"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"}}},"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"}}},"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"}}},"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\\"."}}},"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"}}},"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"}}},"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)"}}},"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)"}}},"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"}}},"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)"}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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)"}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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\\""}}},"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)"}}},"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."}}},"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"}}},"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)"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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)"}}},"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"}}},"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)"}}},"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"}}},"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\\")"}}},"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)"}}},"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"}}},"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\\"])"}}},"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\\")"}}},"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)"}}},"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)"}}},"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)"}}},"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)"}}},"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"}}},"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)"}}},"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"}}},"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)"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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"}}},"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)"}}},"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\\")"}}},"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)"}}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"}},"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"},"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"}}},"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"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}}},"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"},"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."}}},"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"},"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"}}},"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"},"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 (defaults to 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)"}}},"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"},"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\\"]"}}},"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"},"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: , , , ,