diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e0d1d77f3b8..c938b1fd884 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -149,6 +149,10 @@ jobs: working-directory: packages/db run: | bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); for (const extension of ["vector", "btree_gin", "pg_trgm"]) await sql`CREATE EXTENSION IF NOT EXISTS ${sql(extension)}`; await sql.end()' + # A test that hangs on a lock leaves only its timeout behind; these name the waiter, the + # lock, and the blocking statement in the Postgres log. Service containers take no -c flags, + # and ALTER SYSTEM runs outside a transaction, so each setting is its own statement. + bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); for (const setting of ["log_lock_waits = on", "deadlock_timeout = 1000", "log_min_duration_statement = 20000"]) await sql.unsafe(`ALTER SYSTEM SET ${setting}`); await sql`SELECT pg_reload_conf()`; await sql.end()' bun run db:${{ matrix.provision }} - name: Verify migration replay is a no-op diff --git a/.github/workflows/docs-negotiation.yml b/.github/workflows/docs-negotiation.yml index f0ffc918a23..9653b27d007 100644 --- a/.github/workflows/docs-negotiation.yml +++ b/.github/workflows/docs-negotiation.yml @@ -268,6 +268,7 @@ jobs: DOCS_GENERATORS_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/generators/report.json DOCS_COMPATIBILITY_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/compatibility.json DOCS_HEADER_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/docs-headers.json + GITLAB_EXPORT_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/gitlab-export.json steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -292,6 +293,8 @@ jobs: - name: Verify native header edge cases if: ${{ !cancelled() && steps.generators.outcome != 'skipped' }} run: bun run --cwd apps/docs test:headers:e2e + - name: Verify GitLab CSV export over HTTPS + run: python3 apps/docs/scripts/test-gitlab-export-e2e.py - name: Bound the generated content cache if: always() run: bash .github/actions/docs-content-cache/prune.sh content diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3b33249a679..11eecdcbb00 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -234,11 +234,10 @@ Cadence: Electron ships a major every ~8 weeks and supports the latest 3 — bud ## Native Mac computer use -Mothership's `computer` tool is available on macOS 14+ when the global runtime flag -`mothership-computer-use` is enabled and the user opts in under **Settings → Desktop → Computer Use**. -The flag defaults off; local/self-hosted deployments use `MSHIP_COMPUTER_USE=true`, while hosted -rollout uses the existing AppConfig feature-flags document. Older desktop builds omit this optional -bridge and continue to work. +Mothership's `computer` tool is available on macOS 14+ once the user opts in under +**Settings → Desktop → Computer Use**. Enterprise organizations can withhold it from a permission +group with the **Computer Use** restriction. Older desktop builds omit this optional bridge and +continue to work. The user grants Accessibility and Screen Recording through macOS, then approves each app for the current task or persistently. Persistent app approvals are listed in Settings and can be revoked. diff --git a/apps/desktop/e2e/browser-tools.spec.ts b/apps/desktop/e2e/browser-tools.spec.ts index 92e5e18d429..bf3d323751f 100644 --- a/apps/desktop/e2e/browser-tools.spec.ts +++ b/apps/desktop/e2e/browser-tools.spec.ts @@ -1365,7 +1365,7 @@ test.describe('browser tools', () => { expect(await formState()).toMatchObject({ name: 'Observed value' }) const result = fill.result as { observation: { result: { matches: { elementId: number }[] } } } const freshId = result.observation.result.matches[0].elementId - expect(freshId).not.toBe(ref('Name')) + expect(freshId).toBe(ref('Name')) const clear = await execute('browser_type', { elementId: freshId, text: '', observe: {} }) expect(clear.ok, clear.error).toBe(true) expect(clear.result).toMatchObject({ observation: { ok: true } }) diff --git a/apps/desktop/e2e/local-files.spec.ts b/apps/desktop/e2e/local-files.spec.ts index 68d8c1a1274..a00fe40cf40 100644 --- a/apps/desktop/e2e/local-files.spec.ts +++ b/apps/desktop/e2e/local-files.spec.ts @@ -60,6 +60,7 @@ test('native file tools remember folder consent across chats and restarts until stdin: { contents: `import { createRoot } from 'react-dom/client'; import { ToastProvider } from '@sim/emcn'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'; import { PathParamsContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'; import { Desktop } from '@/app/workspace/[workspaceId]/settings/components/desktop/desktop'; @@ -70,11 +71,11 @@ const router = { bfcacheId: 'fixture', back: () => history.back(), forward: () = createRoot(document.getElementById('settings')).render( - + - + );`, diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index fccd280a9ec..dceb49ecbd5 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -1507,13 +1507,13 @@ describe('browserToolWatchdogMs', () => { }) it.each([ - ['number', 30_000, 35_000], - ['numeric string', '30000', 35_000], - ['absent', undefined, 15_000], - ['non-numeric', 'soon', 15_000], - ['zero', 0, 15_000], - ['negative', -5_000, 15_000], - ['above the wait clamp', 500_000, 125_000], + ['number', 30_000, 55_000], + ['numeric string', '30000', 55_000], + ['absent', undefined, 35_000], + ['non-numeric', 'soon', 35_000], + ['zero', 0, 35_000], + ['negative', -5_000, 35_000], + ['above the wait clamp', 500_000, 145_000], ])('normalizes browser_wait_for timeout (%s)', (_label, timeoutMs, expected) => { const params = timeoutMs === undefined ? {} : { timeoutMs } @@ -3098,40 +3098,180 @@ describe('credential protection', () => { }) }) + it('gives a wait that runs to its timeout a full snapshot budget for its closing observation', async () => { + const contents = await openPage() + let finishSnapshot: (value: unknown) => void = () => {} + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { + if (!isPageCall(expression, 'collectSnapshot')) return Promise.resolve(undefined) + return new Promise((resolve) => { + finishSnapshot = resolve + }) + }) + vi.useFakeTimers() + try { + const waiting = driver.executeTool('chat-test', 'browser_wait_for', { timeoutMs: 1_000 }) + await vi.advanceTimersByTimeAsync(9_000) + finishSnapshot({ + url: 'https://example.com/', + title: 'Example', + outline: '- heading "Order confirmed"', + truncated: false, + refIds: [], + refLineIndexes: {}, + nextElementId: 1_000, + }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(await waiting).toMatchObject({ + ok: true, + result: { observation: { ok: true, result: { outline: '- heading "Order confirmed"' } } }, + }) + } finally { + vi.useRealTimers() + } + }) + + it('accepts a later snapshot that keeps refs this scope issued for the same page', async () => { + const contents = await openPage() + const snapshot = (refIds: number[], nextElementId: number) => ({ + url: 'https://example.com/', + title: 'Example', + outline: refIds.map((ref) => `- button "B${ref}" [ref=${ref}]`).join('\n'), + truncated: false, + refIds, + refLineIndexes: Object.fromEntries(refIds.map((ref, index) => [ref, index])), + nextElementId, + }) + respondWith(contents, { collectSnapshot: snapshot([4, 5], 6) }) + await driver.executeTool('chat-test', 'browser_snapshot', {}) + respondWith(contents, { collectSnapshot: snapshot([4, 6], 7) }) + + const kept = await driver.executeTool('chat-test', 'browser_snapshot', {}) + expect(kept).toMatchObject({ ok: true }) + respondWith(contents, { collectSnapshot: snapshot([2], 7) }) + + const forged = await driver.executeTool('chat-test', 'browser_snapshot', {}) + expect(forged).toMatchObject({ ok: false }) + }) + + it("forgets a page's oldest refs once its history passes the retention bound", async () => { + const contents = await openPage() + const snapshot = (refIds: number[], nextElementId: number) => ({ + url: 'https://example.com/', + title: 'Example', + outline: refIds.map((ref) => `- button "B${ref}" [ref=${ref}]`).join('\n'), + truncated: false, + refIds, + refLineIndexes: Object.fromEntries(refIds.map((ref, index) => [ref, index])), + nextElementId, + }) + let next = 0 + for (let round = 0; round < 19; round++) { + const refIds = Array.from({ length: 300 }, (_, index) => next + index) + next += 300 + respondWith(contents, { collectSnapshot: snapshot(refIds, next) }) + expect(await driver.executeTool('chat-test', 'browser_snapshot', {})).toMatchObject({ + ok: true, + }) + } + respondWith(contents, { collectSnapshot: snapshot([next - 1], next) }) + expect(await driver.executeTool('chat-test', 'browser_snapshot', {})).toMatchObject({ + ok: true, + }) + respondWith(contents, { collectSnapshot: snapshot([0], next) }) + + expect(await driver.executeTool('chat-test', 'browser_snapshot', {})).toMatchObject({ + ok: false, + }) + }) + + it('recaptures with fresh ids when the page reuses an id this scope never committed', async () => { + const contents = await openPage() + const snapshot = (refIds: number[], nextElementId: number) => ({ + url: 'https://example.com/', + title: 'Example', + outline: refIds.map((ref) => `- button "B${ref}" [ref=${ref}]`).join('\n'), + truncated: false, + refIds, + refLineIndexes: Object.fromEntries(refIds.map((ref, index) => [ref, index])), + nextElementId, + }) + respondWith(contents, { collectSnapshot: snapshot([4, 5], 6) }) + await driver.executeTool('chat-test', 'browser_snapshot', {}) + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { + if (!isPageCall(expression, 'collectSnapshot')) return Promise.resolve(undefined) + const forgets = expression.includes(',true])') + return Promise.resolve(forgets ? snapshot([6, 7], 8) : snapshot([2, 6], 7)) + }) + + const result = await driver.executeTool('chat-test', 'browser_snapshot', {}) + + expect(result).toMatchObject({ + ok: true, + result: { outline: expect.stringContaining('[ref=7]') }, + }) + }) + + it('acts on a ref an earlier snapshot of the same page issued', async () => { + const contents = await openPage() + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/', + title: 'Example', + outline: '- button "Pins" [ref=4]\n- button "Files" [ref=5]', + truncated: false, + refIds: [4, 5], + refLineIndexes: { 4: 0, 5: 1 }, + nextElementId: 6, + }, + }) + await driver.executeTool('chat-test', 'browser_snapshot', {}) + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/', + title: 'Example', + outline: '- dialog "Pick a time" [ref=6]', + truncated: false, + refIds: [6], + refLineIndexes: { 6: 0 }, + nextElementId: 7, + }, + }) + await driver.executeTool('chat-test', 'browser_find', { query: 'time' }) + + const clicked = await driver.executeTool('chat-test', 'browser_click', { elementId: 4 }) + expect(clicked).toMatchObject({ ok: true, result: { dispatched: true } }) + }) + it.each([ ['browser_snapshot', 'true'], ['browser_find', 'false'], - ] as const)( - 'passes the current root ref to %s and invalidates previous refs', - async (tool, markNew) => { - const contents = await openPage() - respondWith(contents, { - collectSnapshot: { - url: 'https://example.com/login', - title: 'Scoped', - scoped: true, - outline: '- button "Save" [ref=1]', - truncated: false, - refIds: [1], - refLineIndexes: { 1: 0 }, - nextElementId: 2, - }, - }) - const result = await driver.executeTool('chat-test', tool, { elementId: 0, query: 'Save' }) - expect(result).toMatchObject({ ok: true, result: { scoped: true } }) - expect( - vi - .mocked(contents.executeJavaScript) - .mock.calls.some( - ([expression]) => - isPageCall(expression, 'collectSnapshot') && - expression.includes(`.apply(null, [1,0,${markNew}])`) - ) - ).toBe(true) - const stale = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - expect(stale).toMatchObject({ ok: false }) - } - ) + ] as const)('passes the current root ref to %s', async (tool, markNew) => { + const contents = await openPage() + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/login', + title: 'Scoped', + scoped: true, + outline: '- button "Save" [ref=1]', + truncated: false, + refIds: [1], + refLineIndexes: { 1: 0 }, + nextElementId: 2, + }, + }) + const result = await driver.executeTool('chat-test', tool, { elementId: 0, query: 'Save' }) + expect(result).toMatchObject({ ok: true, result: { scoped: true } }) + expect( + vi + .mocked(contents.executeJavaScript) + .mock.calls.some( + ([expression]) => + isPageCall(expression, 'collectSnapshot') && + expression.includes(`.apply(null, [1,0,${markNew},`) + ) + ).toBe(true) + }) it.each([ { kind: 'input:checkbox', checked: true, disabled: false, readOnly: false }, diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index d0ada80bf5f..02ae25dc4b3 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -88,6 +88,7 @@ import { } from '@/main/browser-agent/page-prompts' import { isPanelVisible, panelWindow } from '@/main/browser-agent/panel' import { + observeRequest, withFailedPostActionObservation, withPostActionObservation, } from '@/main/browser-agent/post-action-observation' @@ -144,6 +145,24 @@ function isBrowserWaitElementState(value: string): value is BrowserWaitElementSt type PageExecutionTarget = WebContents | WebFrameMain +/** + * Refs remembered per page or frame. Above the page's own 5000-ref store by + * one snapshot's 300 refs, and refreshed by the same snapshots, so the driver + * never forgets a ref the page can still hand back. + */ +const ISSUED_REFS_PER_TARGET = 5300 + +/** Whether two targets are the same page or frame; Electron may hand out separate wrappers for one frame. */ +function samePageTarget( + left: PageExecutionTarget | undefined, + right: PageExecutionTarget +): boolean { + if (left === right) return true + return ( + left !== undefined && 'routingId' in left && 'routingId' in right && sameWebFrame(left, right) + ) +} + type BrowserActionOutcome = { status: 'pending' } | { status: 'acknowledged'; result: unknown } type FormField = @@ -326,6 +345,10 @@ interface DriverScopeState { snapshotTargets: Map /** Monotonic ref floor shared by every frame in this browser scope. */ nextElementRefId: number + /** Every ref this scope issued and its frame: a later snapshot may reuse it there, and an action may still name it. */ + issuedRefTargets: Map + /** The same refs per page or frame, oldest first, so each page's history stays bounded. */ + issuedRefsByTarget: Map> /** Invalidates captures that finish after a tab/navigation/timeout race. */ snapshotCaptureEpoch: number /** Cancels a timed-out multi-step action before any later native dispatch. */ @@ -367,6 +390,8 @@ function createDriverScopeState(): DriverScopeState { snapshotTabId: null, snapshotTargets: new Map(), nextElementRefId: 0, + issuedRefTargets: new Map(), + issuedRefsByTarget: new Map(), snapshotCaptureEpoch: 0, toolExecutionEpoch: 0, } @@ -1136,8 +1161,9 @@ export function browserToolWatchdogMs( return BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS } if (tool === 'browser_wait_for') { + // A wait ends with a snapshot, which gets the same budget a standalone snapshot does. const requested = normalizeBrowserWaitForTimeoutMs(params.timeoutMs) - return requested + WAIT_FOR_TOOL_WATCHDOG_GRACE_MS + return requested + WAIT_FOR_TOOL_WATCHDOG_GRACE_MS + DEFAULT_TOOL_WATCHDOG_MS } return DEFAULT_TOOL_WATCHDOG_MS } @@ -1766,9 +1792,44 @@ function requireSnapshotForElementAction(): void { ) } +function rememberIssuedRef( + state: DriverScopeState, + ref: number, + target: PageExecutionTarget +): void { + const previous = state.issuedRefTargets.get(ref) + if (previous && previous !== target) state.issuedRefsByTarget.get(previous)?.delete(ref) + state.issuedRefTargets.set(ref, target) + const refs = state.issuedRefsByTarget.get(target) ?? new Set() + state.issuedRefsByTarget.set(target, refs) + refs.delete(ref) + refs.add(ref) + const oldest = refs.values().next() + if (refs.size > ISSUED_REFS_PER_TARGET && !oldest.done) { + refs.delete(oldest.value) + state.issuedRefTargets.delete(oldest.value) + } +} + +/** + * The frame that owns a ref: the latest snapshot's, or for a ref an earlier + * snapshot issued, its frame while that frame still belongs to the tab the + * latest snapshot covered. The page then confirms the element itself. + */ +function elementTarget(elementId: number): PageExecutionTarget | undefined { + const state = driverScopeState() + const latest = state.snapshotTargets.get(elementId) + if (latest) return latest + const issued = state.issuedRefTargets.get(elementId) + const contents = session.automationTab()?.view.webContents + if (!issued || !contents || contents.isDestroyed()) return undefined + if (!('routingId' in issued)) return issued === contents ? contents : undefined + return liveFrameInTab(contents, issued) +} + function pageTargetForElement(elementId: number): PageExecutionTarget { requireSnapshotForElementAction() - const target = driverScopeState().snapshotTargets.get(elementId) + const target = elementTarget(elementId) if (!target || ('isDestroyed' in target && target.isDestroyed())) { throw new ToolError( 'That element id is not present in the current snapshot. Call browser_snapshot and use a visible ref from that result.' @@ -1865,7 +1926,7 @@ function assertElementActionCurrent( assertActiveContents(contents) const state = driverScopeState() const active = session.automationTab() - if (state.snapshotTabId !== active?.id || state.snapshotTargets.get(elementId) !== target) { + if (state.snapshotTabId !== active?.id || !samePageTarget(elementTarget(elementId), target)) { throw new ToolError( 'The page changed before input could be dispatched. Take a fresh browser_snapshot and try again.' ) @@ -1877,6 +1938,13 @@ function focusedPageTarget(contents: WebContents): PageExecutionTarget { return focused && focused !== contents.mainFrame && !focused.isDestroyed() ? focused : contents } +/** The tab's current wrapper for a frame, if the frame is still live in that tab. */ +function liveFrameInTab(contents: WebContents, frame: WebFrameMain): WebFrameMain | undefined { + return contents.mainFrame.framesInSubtree.find( + (candidate) => !candidate.isDestroyed() && sameWebFrame(candidate, frame) + ) +} + function sameWebFrame(left: WebFrameMain, right: WebFrameMain): boolean { if (left === right) return true if (Number.isSafeInteger(left.frameTreeNodeId) && Number.isSafeInteger(right.frameTreeNodeId)) { @@ -1899,11 +1967,7 @@ function assertFocusedTargetUnchanged( expectedFrameNavigationEpoch?: number ): void { const focused = focusedPageTarget(contents) - const unchanged = - target === contents - ? focused === contents - : focused !== contents && sameWebFrame(focused as WebFrameMain, target as WebFrameMain) - if (!unchanged) { + if (!samePageTarget(focused, target)) { throw new ToolError( 'Focus moved to a different page or frame before the keystroke. No retry was attempted; inspect the page first.' ) @@ -1915,7 +1979,7 @@ function assertFocusedTargetUnchanged( frame.detached || (expectedFrameNavigationEpoch !== undefined && frameNavigationEpoch(contents, frame) !== expectedFrameNavigationEpoch) || - !contents.mainFrame.framesInSubtree.some((candidate) => sameWebFrame(candidate, frame)) + !liveFrameInTab(contents, frame) ) { throw new ToolError( 'The focused frame navigated or was replaced before the keystroke. Inspect the page before continuing.' @@ -2338,7 +2402,8 @@ async function readWholePageText( function validateSnapshotRefs( snapshot: Record, - startingElementId: number + startingElementId: number, + issuedHere: (ref: number) => boolean ): { refs: number[]; nextElementId: number; lineIndexes: Map } | null { if (typeof snapshot.outline !== 'string' || snapshot.outline.length > 500_000) return null if (!Array.isArray(snapshot.refIds) || snapshot.refIds.length > 300) return null @@ -2362,7 +2427,7 @@ function validateSnapshotRefs( if ( typeof value !== 'number' || !Number.isSafeInteger(value) || - value < startingElementId || + (value < startingElementId && !issuedHere(value)) || value >= nextElementId || unique.has(value) ) { @@ -2432,26 +2497,36 @@ async function captureSnapshot( } const mainStartingElementId = state.nextElementRefId - const mainSnapshot = unwrapPageResult( - await execInPage( - contents, - collectSnapshot, - [mainStartingElementId, elementId ?? null, markNew], - false, - notAfter + // A page reuses ids it issued in a capture this scope never committed (an + // aborted or timed-out snapshot); when validation cannot vouch for one, the + // page forgets its issued refs and the capture runs once more. + const captureMain = async (forgetIssuedRefs: boolean) => { + const snapshot = unwrapPageResult( + await execInPage( + contents, + collectSnapshot, + [mainStartingElementId, elementId ?? null, markNew, state.generation, forgetIssuedRefs], + false, + notAfter + ) ) - ) - if (!stillCurrent()) { - throw new ToolError('The tab changed while its snapshot was being captured. Try again.') - } - if ( - !isRecordLike(mainSnapshot) || - typeof mainSnapshot.outline !== 'string' || - !Array.isArray(mainSnapshot.refIds) - ) { - throw new ToolError('The page returned an incomplete snapshot. Try browser_snapshot again.') + if (!stillCurrent()) { + throw new ToolError('The tab changed while its snapshot was being captured. Try again.') + } + if ( + !isRecordLike(snapshot) || + typeof snapshot.outline !== 'string' || + !Array.isArray(snapshot.refIds) + ) { + throw new ToolError('The page returned an incomplete snapshot. Try browser_snapshot again.') + } + const refs = validateSnapshotRefs(snapshot, mainStartingElementId, (ref) => + samePageTarget(state.issuedRefTargets.get(ref), contents) + ) + return { snapshot, refs } } - const mainRefs = validateSnapshotRefs(mainSnapshot, mainStartingElementId) + let { snapshot: mainSnapshot, refs: mainRefs } = await captureMain(false) + if (!mainRefs) ({ snapshot: mainSnapshot, refs: mainRefs } = await captureMain(true)) if (!mainRefs) { throw new ToolError('The page returned invalid element ids. Try browser_snapshot again.') } @@ -2490,13 +2565,15 @@ async function captureSnapshot( } const frameUrl = frame.url const frameStartingElementId = nextElementId - const frameSnapshot = await execInPage( - frame, - collectSnapshot, - [frameStartingElementId, null, markNew], - false, - notAfter - ) + const captureFrame = (forgetIssuedRefs: boolean) => + execInPage( + frame, + collectSnapshot, + [frameStartingElementId, null, markNew, state.generation, forgetIssuedRefs], + false, + notAfter + ) + let frameSnapshot = await captureFrame(false) if (!stillCurrent()) { throw new ToolError('The tab changed while its frames were being captured. Try again.') } @@ -2512,8 +2589,25 @@ async function captureSnapshot( unreadableCrossOriginFrames++ continue } - const frameRefs = validateSnapshotRefs(frameSnapshot, frameStartingElementId) + const validateFrame = (snapshot: Record) => + validateSnapshotRefs(snapshot, frameStartingElementId, (ref) => + samePageTarget(state.issuedRefTargets.get(ref), frame) + ) + let frameRefs = validateFrame(frameSnapshot) if (!frameRefs) { + frameSnapshot = await captureFrame(true) + if (!stillCurrent()) { + throw new ToolError('The tab changed while its frames were being captured. Try again.') + } + if (frame.isDestroyed() || frame.detached || frame.url !== frameUrl) { + unreadableCrossOriginFrames++ + continue + } + if (isRecordLike(frameSnapshot) && Array.isArray(frameSnapshot.refIds)) { + frameRefs = validateFrame(frameSnapshot) + } + } + if (!frameRefs || !isRecordLike(frameSnapshot) || typeof frameSnapshot.outline !== 'string') { unreadableCrossOriginFrames++ continue } @@ -2565,6 +2659,12 @@ async function captureSnapshot( (targetLineIndexes.get(elementId) ?? Number.POSITIVE_INFINITY) < visibleOutlineLines.length ) ) + for (const [target, refs] of state.issuedRefsByTarget) { + if (!('isDestroyed' in target && target.isDestroyed())) continue + for (const ref of refs) state.issuedRefTargets.delete(ref) + state.issuedRefsByTarget.delete(target) + } + for (const [ref, target] of targets) rememberIssuedRef(state, ref, target) state.snapshotTabId = capturedTabId return { ...omit(mainSnapshot, ['refIds', 'refLineIndexes', 'nextElementId']), @@ -2872,8 +2972,8 @@ async function executeToolInner( } const waitedTab = session.requireAutomationTab() const contents = waitedTab.view.webContents - const elementTarget = elementId === undefined ? undefined : pageTargetForElement(elementId) - if (elementTarget && elementTarget !== contents) { + const waitTarget = elementId === undefined ? undefined : pageTargetForElement(elementId) + if (waitTarget && waitTarget !== contents) { throw new ToolError( 'Element-state waits are limited to the top page. Use a text or URL condition for framed content.' ) @@ -2883,7 +2983,7 @@ async function executeToolInner( const assertWaitTargetIsCurrent = (): void => { assertCurrentExecution() if ( - elementTarget && + waitTarget && (navigationEpoch(contents) !== waitedNavigationEpoch || contents.getURL() !== waitedUrl) ) { throw new ToolError( @@ -2926,12 +3026,12 @@ async function executeToolInner( } const urlMatched = !urlContains || contents.getURL().includes(urlContains) let elementMatched = elementId === undefined - if (elementId !== undefined && elementState && elementTarget) { + if (elementId !== undefined && elementState && waitTarget) { assertWaitTargetIsCurrent() const state = toRecord( unwrapPageResult( await execInPage( - elementTarget, + waitTarget, readPageActionState, [false, elementId, 'registered'], false, @@ -3557,6 +3657,9 @@ async function executeToolInner( activation, element: prepared.element, refRecovered: prepared.refRecovered === true, + ...(typeof prepared.clickedThrough === 'string' + ? { clickedThrough: prepared.clickedThrough } + : {}), effectObserved, possibleEffectObserved, effect, @@ -4042,6 +4145,11 @@ async function executeToolInner( }, } const notes: string[] = [] + if (typeof initialSurface.coveredBy === 'string') { + notes.push( + `The field is covered by ${initialSurface.coveredBy}, so the text went in through keyboard focus.` + ) + } if (submitNote) notes.push(submitNote) if (!combinedObservation.effectObserved) { notes.push( @@ -5347,7 +5455,9 @@ export async function executeTool( actionOutcome = outcome } ) - if (params.observe !== undefined) actionOutcome = { status: 'acknowledged', result } + if (observeRequest(tool, params) !== undefined) { + actionOutcome = { status: 'acknowledged', result } + } return result }, (query) => @@ -5372,7 +5482,7 @@ export async function executeTool( tool === 'browser_snapshot' || tool === 'browser_open_url' || tool === 'browser_find' || - params.observe !== undefined + observeRequest(tool, params) !== undefined ) { invalidateSnapshot(state) } @@ -5382,7 +5492,7 @@ export async function executeTool( observedResult = await guardedExecution } catch (error) { if (!actionOutcome) { - if (params.observe !== undefined) cancelPendingSnapshotCapture(state) + if (observeRequest(tool, params) !== undefined) cancelPendingSnapshotCapture(state) throw error } invalidateSnapshot(state) diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index c9b6ef73e33..2c394eba4b2 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -117,6 +117,7 @@ beforeEach(() => { window.__simAgentMutationStates = undefined window.__simAgentNextElementId = 0 window.__simAgentShownElements = undefined + window.__simAgentShownPending = undefined installPageHelpers() window.__simAgentResolveElement = undefined installDomShims() @@ -334,8 +335,8 @@ describe('combobox typing surfaces', () => { expect(input.value).toBe('Mondu') }) - it('refuses mixed or unrelated blockers instead of treating them as suggestions', () => { - const { option } = composeField() + it('does not treat mixed or unrelated blockers as suggestions, and types through keyboard focus', () => { + const { input, option } = composeField() const overlay = visible(document.createElement('div')) overlay.setAttribute('aria-label', 'Unrelated overlay') document.body.append(overlay) @@ -344,11 +345,53 @@ describe('combobox typing surfaces', () => { value: (x: number) => (x > 70 ? overlay : option), }) - expect(focusElementForTyping(0)).toEqual({ - error: 'obstructed', - blocker: 'Mondu', - blockerControls: [], + expect(focusElementForTyping(0)).toMatchObject({ + focused: true, + coveredBy: 'Mondu', + coveredByRelatedPopup: false, + }) + expect(document.activeElement).toBe(input) + }) + + it('types into a top-page field a promo card covers, naming the cover', () => { + document.body.innerHTML = + '
Shop for your home
' + const input = visible(document.querySelector('input') as HTMLInputElement) + const card = visible(document.querySelector('div') as HTMLDivElement) + register(input) + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => card }) + + expect(focusElementForTyping(0)).toMatchObject({ + focused: true, + coveredBy: 'Shop for your home', }) + expect(typeIntoElement(0, 'cooking pot', false)).toMatchObject({ dispatched: true }) + expect(input.value).toBe('cooking pot') + }) + + it('still refuses a top-page field a modal dialog covers', () => { + document.body.innerHTML = + '

Sign in

' + const input = visible(document.querySelector('input') as HTMLInputElement) + const cover = visible(document.querySelector('p') as HTMLParagraphElement) + register(input) + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => cover }) + + expect(focusElementForTyping(0)).toMatchObject({ error: 'obstructed' }) + }) + + it('still refuses a covered field inside a frame', () => { + const frame = document.createElement('iframe') + document.body.append(frame) + const inner = frame.contentDocument as Document + inner.body.innerHTML = '
Cover
' + const field = visible(inner.querySelector('input') as HTMLInputElement) + const cover = visible(inner.querySelector('div') as HTMLDivElement) + field.scrollIntoView = () => {} + register(field) + Object.defineProperty(inner, 'elementFromPoint', { configurable: true, value: () => cover }) + + expect(focusElementForTyping(0)).toMatchObject({ error: 'obstructed', blocker: 'Cover' }) }) it('refuses ambiguous composite fields and descendant passwords', () => { @@ -500,6 +543,19 @@ describe('collectSnapshot', () => { expect(outlineOf(collectSnapshot())).not.toContain(' new') }) + it('keeps new markers for a recapture that replaces a discarded capture', () => { + document.body.innerHTML = '' + visible(document.querySelector('button') as HTMLButtonElement) + collectSnapshot() + const added = document.createElement('button') + added.textContent = 'Send' + document.body.append(visible(added)) + collectSnapshot() + + const lines = outlineOf(collectSnapshot(0, null, true, 0, true)).split('\n') + expect(lines.find((line) => line.includes('"Send"'))).toMatch(/ new$/) + }) + it('leaves new markers out of an unmarked read without consuming them', () => { document.body.innerHTML = '' visible(document.querySelector('button') as HTMLButtonElement) @@ -651,6 +707,57 @@ describe('collectSnapshot', () => { expect(focusElementForTyping(ref)).toMatchObject({ error: 'stale' }) }) + it('clicks through a nested control that fills the whole target', () => { + document.body.innerHTML = '3:00 PM' + const link = visible(document.querySelector('a') as HTMLAnchorElement) + const slot = visible(document.querySelector('span') as HTMLSpanElement) + register(link) + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => slot }) + + expect(clickElement(0, false)).toMatchObject({ clickedThrough: '3:00 PM' }) + }) + + it('refuses a nested control that leaves the target edges uncovered', () => { + document.body.innerHTML = 'Open' + const link = visible(document.querySelector('a') as HTMLAnchorElement) + const inner = document.querySelector('span') as HTMLSpanElement + inner.getBoundingClientRect = () => + ({ x: 15, y: 3, width: 70, height: 14, left: 15, top: 3, right: 85, bottom: 17 }) as DOMRect + register(link) + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => inner }) + + expect(clickElement(0, false)).toMatchObject({ error: 'nested-control' }) + }) + + it('refuses to click through to a form field or through a synthetic fallback', () => { + document.body.innerHTML = '
' + const wrap = visible(document.querySelector('#wrap') as HTMLDivElement) + const input = visible(document.querySelector('input') as HTMLInputElement) + register(wrap) + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => input }) + expect(clickElement(0, false)).toMatchObject({ error: 'nested-control' }) + + document.body.innerHTML = '3:00 PM' + const link = visible(document.querySelector('a') as HTMLAnchorElement) + const slot = visible(document.querySelector('span') as HTMLSpanElement) + register(link) + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => slot }) + expect(clickElement(0)).toMatchObject({ error: 'nested-control' }) + }) + + it('still refuses a nested control that covers only part of the target', () => { + document.body.innerHTML = 'Card title ' + const link = visible(document.querySelector('a') as HTMLAnchorElement) + const save = visible(document.querySelector('button') as HTMLButtonElement) + register(link) + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: (x: number) => (x <= 50 ? save : document.body), + }) + + expect(clickElement(0)).toMatchObject({ error: 'nested-control' }) + }) + it('refuses to recover a ref when replacement is ambiguous', () => { document.body.innerHTML = '' const original = visible(document.querySelector('button') as HTMLButtonElement) @@ -673,16 +780,141 @@ describe('collectSnapshot', () => { expect(clickElement(ref)).toMatchObject({ error: 'stale' }) }) - it('never recycles numeric refs across snapshots', () => { + it('keeps a connected ref whose name grew when its own submenu opened', () => { + document.body.innerHTML = + '
Products ▾
    ' + const menu = visible(document.querySelector('[role="button"]') as HTMLDivElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Products ▾') + + ;(menu.querySelector('ul') as HTMLUListElement).innerHTML = + '
  • Starter
  • Business
  • ' + + expect(clickElement(ref)).toMatchObject({ dispatched: true }) + }) + + it('acts on a text line whose accessible name differs from its rendered text', () => { + document.body.innerHTML = '

    i

    ' + visible(document.querySelector('span') as HTMLSpanElement) + const ref = refFor(outlineOf(collectSnapshot()), 'i') + + expect(clickElement(ref)).toMatchObject({ dispatched: true }) + }) + + it('acts on a text line longer than the identity name budget', () => { + const text = 'Terms of service '.repeat(10).trim() + document.body.innerHTML = `

    ${text}

    ` + visible(document.querySelector('p') as HTMLParagraphElement) + const ref = refFor(outlineOf(collectSnapshot()), text.slice(0, 160)) + + expect(clickElement(ref)).toMatchObject({ dispatched: true }) + }) + + it('invalidates a recycled row whose new name merely extends the old one', () => { + document.body.innerHTML = '
    Alice
    ' + const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Alice') + + row.textContent = 'Alice Smith' + + expect(clickElement(ref)).toMatchObject({ error: 'stale' }) + }) + + it('gives a row control a new ref when its row now shows a different record', () => { + document.body.innerHTML = '
    Order 1
    ' + for (const el of Array.from(document.querySelectorAll('span, button'))) visible(el) + const first = refFor(outlineOf(collectSnapshot()), 'Delete') + ;(document.querySelector('span') as HTMLSpanElement).textContent = 'Order 2' + + expect(refFor(outlineOf(collectSnapshot()), 'Delete')).not.toBe(first) + }) + + it('refuses an earlier ref once a later snapshot reissued its recycled element', () => { + document.body.innerHTML = '
    Order 1
    ' + for (const el of Array.from(document.querySelectorAll('span, button'))) visible(el) + const first = refFor(outlineOf(collectSnapshot()), 'Archive') + ;(document.querySelector('span') as HTMLSpanElement).textContent = 'Order 2' + const second = refFor(outlineOf(collectSnapshot()), 'Archive') + collectSnapshot(500, second) + + expect(second).not.toBe(first) + expect(clickElement(first)).toMatchObject({ error: 'stale' }) + }) + + it('does not take an icon-only control as revealing content when its label grows', () => { + document.body.innerHTML = '' + const button = visible(document.querySelector('button') as HTMLButtonElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Remove') + + button.setAttribute('aria-label', 'Remove member') + + expect(clickElement(ref)).toMatchObject({ error: 'stale' }) + }) + + it('invalidates a connected ref whose name changed to a longer, different word', () => { + document.body.innerHTML = '
    Item 1
    ' + const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Item 1') + + row.textContent = 'Item 12' + + expect(clickElement(ref)).toMatchObject({ error: 'stale' }) + }) + + it('keeps an unchanged element on the same ref across snapshots', () => { + document.body.innerHTML = '' + for (const button of Array.from(document.querySelectorAll('button'))) visible(button) + const first = outlineOf(collectSnapshot()) + const second = outlineOf(collectSnapshot()) + + expect(refFor(second, 'Pins')).toBe(refFor(first, 'Pins')) + expect(refFor(second, 'Files')).toBe(refFor(first, 'Files')) + expect(clickElement(refFor(first, 'Pins'))).toMatchObject({ dispatched: true }) + }) + + it('acts on a ref from an earlier snapshot that a later scoped snapshot left out', () => { + document.body.innerHTML = '
    ' + for (const button of Array.from(document.querySelectorAll('button'))) visible(button) + const pins = refFor(outlineOf(collectSnapshot()), 'Pins') + const section = refFor(outlineOf(collectSnapshot()), 'Files') + collectSnapshot(100, section) + + expect(clickElement(pins)).toMatchObject({ dispatched: true }) + }) + + it('refuses a ref from an earlier snapshot once its element left the DOM', () => { + document.body.innerHTML = '' + for (const button of Array.from(document.querySelectorAll('button'))) visible(button) + const pins = refFor(outlineOf(collectSnapshot()), 'Pins') + ;(document.querySelector('button') as HTMLButtonElement).remove() + collectSnapshot() + + expect(clickElement(pins)).toMatchObject({ error: 'stale' }) + }) + + it('never gives a ref to a different element across snapshots', () => { document.body.innerHTML = '' - visible(document.querySelector('button') as HTMLButtonElement) + const original = visible(document.querySelector('button') as HTMLButtonElement) const firstRef = refFor(outlineOf(collectSnapshot()), 'Pins') - const secondRef = refFor(outlineOf(collectSnapshot()), 'Pins') + original.replaceWith(visible(document.createElement('button'))) + ;(document.querySelector('button') as HTMLButtonElement).textContent = 'Files' + const secondRef = refFor(outlineOf(collectSnapshot()), 'Files') expect(secondRef).toBeGreaterThan(firstRef) expect(clickElement(firstRef)).toMatchObject({ error: 'stale' }) expect(clickElement(secondRef)).toMatchObject({ dispatched: true }) }) + + it('gives a recycled row a new ref when its identity changed in place', () => { + document.body.innerHTML = '
    eng-bugs
    ' + const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement) + const firstRef = refFor(outlineOf(collectSnapshot()), 'eng-bugs') + row.textContent = 'random' + row.dataset.key = 'channel-2' + const secondRef = refFor(outlineOf(collectSnapshot()), 'random') + + expect(secondRef).not.toBe(firstRef) + expect(clickElement(firstRef)).toMatchObject({ error: 'stale' }) + }) }) describe('semantic control state', () => { diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 3cfa32e0a8a..a938eba740d 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -28,6 +28,19 @@ * the keystroke refusal that read it. */ +/** What a snapshot recorded about an element, so a later action can confirm it is the same one. */ +interface SnapshotRefLocator { + url: string + tag: string + role: string + name: string + attributes: Record + ancestor: string + context: string + /** Hash of the element's DOM text, to tell revealed content from replaced content. */ + textHash: number +} + declare global { interface Window { __simAgentElements?: Element[] @@ -43,8 +56,17 @@ declare global { observedRoots: WeakSet }> __simAgentNextElementId?: number + /** Every ref this document issued, resolvable while its element keeps that identity. */ + __simAgentIssuedRefs?: Map< + number, + { element: WeakRef; locator: SnapshotRefLocator; namespace: number } + > + /** The ref each element was last issued, so an unchanged element keeps it. */ + __simAgentRefOfElement?: WeakMap /** Elements an earlier snapshot of this document listed; a later snapshot marks the rest `new`. */ __simAgentShownElements?: WeakSet + /** Elements the latest capture listed, committed to the shown set by the next capture that keeps them. */ + __simAgentShownPending?: Element[] /** Installed by {@link installPageHelpers} before every page function; cached per call. */ __simAgentIsExemptModal: (element: Element) => boolean /** Installed by {@link installPageHelpers}: the element's id in the current snapshot. */ @@ -210,7 +232,9 @@ export function installPageHelpers(): void { export function collectSnapshot( startingElementId = 0, elementId: number | null = null, - markNew = true + markNew = true, + refNamespace = 0, + forgetIssuedRefs = false ): unknown { const resolver = window.__simAgentResolveElement const scopedRoot = @@ -308,24 +332,24 @@ export function collectSnapshot( const registry: Element[] = [] const refLineIndexes: Record = {} - const locators: Array<{ - url: string - tag: string - role: string - name: string - attributes: Record - ancestor: string - context: string - }> = [] + const locators: SnapshotRefLocator[] = [] window.__simAgentElements = registry - const previouslyShown = window.__simAgentShownElements - const shown = previouslyShown ?? new WeakSet() - if (markNew) window.__simAgentShownElements = shown + // A capture the driver discards and recaptures must not use up `new` + // markers, so its listed elements commit only when the next capture keeps them. + const pendingShown = window.__simAgentShownPending + if (markNew && pendingShown && !forgetIssuedRefs) { + const committed = window.__simAgentShownElements ?? new WeakSet() + for (const el of pendingShown) committed.add(el) + window.__simAgentShownElements = committed + } + const shown = window.__simAgentShownElements + const listed: Element[] = [] + if (markNew) window.__simAgentShownPending = listed /** Whether no earlier snapshot of this document listed the element; the first snapshot marks nothing. */ - const isNew = (el: Element): boolean => markNew && previouslyShown !== undefined && !shown.has(el) + const isNew = (el: Element): boolean => markNew && shown !== undefined && !shown.has(el) /** Records an element whose line made it into the outline, so the next snapshot knows it. */ const recordShown = (el: Element): void => { - if (markNew) shown.add(el) + if (markNew) listed.push(el) } const lines: string[] = [] let truncated = false @@ -588,10 +612,25 @@ export function collectSnapshot( return '' } + // An unchanged element keeps its ref, and a ref stays resolvable after a + // later snapshot leaves it out; a changed element gets a new ref. + // The driver asks the page to forget when it cannot vouch for a reused id + // (a capture it never committed handed it out), so every element gets a new one. + const issuedRefs = (!forgetIssuedRefs && window.__simAgentIssuedRefs) || new Map() + window.__simAgentIssuedRefs = issuedRefs + const refOfElement = (!forgetIssuedRefs && window.__simAgentRefOfElement) || new WeakMap() + window.__simAgentRefOfElement = refOfElement + const issuedRefCap = 5000 + const textHashOf = (el: Element): number => { + const text = el.textContent || '' + let hash = 0 + for (let index = 0; index < text.length; index++) { + hash = (hash * 31 + text.charCodeAt(index)) | 0 + } + return hash + } const registerElement = (el: Element, role: string, name: string): number => { - const id = nextElementId++ - registry[id] = el - locators[id] = { + const locator = { url: pageUrlFor(el), tag: el.tagName.toUpperCase(), role, @@ -599,7 +638,33 @@ export function collectSnapshot( attributes: locatorAttributes(el), ancestor: ancestorSignature(el), context: contextSignature(el), - } + textHash: textHashOf(el), + } + const priorId = refOfElement.get(el) + const prior = priorId === undefined ? undefined : issuedRefs.get(priorId) + const unchanged = + prior !== undefined && + prior.namespace === refNamespace && + prior.element.deref() === el && + prior.locator.tag === locator.tag && + prior.locator.role === locator.role && + prior.locator.name === locator.name && + prior.locator.ancestor === locator.ancestor && + prior.locator.context === locator.context && + Object.keys({ ...prior.locator.attributes, ...locator.attributes }).every( + (key) => prior.locator.attributes[key] === locator.attributes[key] + ) + const id = + unchanged && priorId !== undefined && registry[priorId] === undefined + ? priorId + : nextElementId++ + refOfElement.set(el, id) + registry[id] = el + locators[id] = locator + issuedRefs.delete(id) + issuedRefs.set(id, { element: new WeakRef(el), locator, namespace: refNamespace }) + const oldest = issuedRefs.keys().next() + if (issuedRefs.size > issuedRefCap && !oldest.done) issuedRefs.delete(oldest.value) refCount++ window.__simAgentNextElementId = nextElementId return id @@ -691,7 +756,8 @@ export function collectSnapshot( 160 ) if (!text) return - const id = registerElement(el, roleFor(el), text) + // The resolver recomputes nameFor, which differs from the shown text for aria-labelled or long leaves. + const id = registerElement(el, roleFor(el), nameFor(el)) textLineCount++ const marker = isNew(el) ? ' new' : '' const lineIndex = lines.length @@ -850,11 +916,15 @@ export function collectSnapshot( * real stale ref, never permission to click something nearby. */ window.__simAgentResolveElement = (id: number, allowRecovery = true) => { - if (scopedRoot && !scopedRoot.isConnected) { + // A ref from an earlier snapshot resolves only to its own live node: no + // lookalike recovery, and the latest snapshot's scope does not apply to it. + const inLatestSnapshot = locators[id] !== undefined + if (inLatestSnapshot && scopedRoot && !scopedRoot.isConnected) { window.__simAgentStaleReason = 'the scoped snapshot root left the DOM' return null } - const locator = locators[id] + const issued = issuedRefs.get(id) + const locator = issued?.locator if (!locator) { window.__simAgentStaleReason = `id ${id} is not in the current snapshot's registry` return null @@ -897,7 +967,29 @@ export function collectSnapshot( ) { return false } - if (nameFor(candidate) !== locator.name) return false + // A mounted control whose own menu opened (often by the hover or click + // being validated) appends that menu to its name: either hidden content + // was revealed (its DOM text is unchanged) or it declares a popup. A + // recycled row changes its DOM text with neither. + const name = nameFor(candidate) + const revealedContent = (): boolean => + Boolean(candidate.textContent?.trim()) && textHashOf(candidate) === locator.textHash + const opensPopup = (): boolean => + revealedContent() || + ['aria-haspopup', 'aria-expanded', 'aria-controls'].some( + (attribute) => (candidate.getAttribute(attribute) || 'false') !== 'false' + ) || + candidate.querySelector( + '[role="menu"], [role="menubar"], [role="listbox"], [role="tooltip"], [role="dialog"], [role="tree"]' + ) !== null + const nameGrewInPlace = + connected && + name !== locator.name && + locator.name.length > 0 && + name.startsWith(locator.name) && + (locator.name.length >= 120 || /\s/.test(name.charAt(locator.name.length))) && + opensPopup() + if (name !== locator.name && !nameGrewInPlace) return false const candidateAttributes = locatorAttributes(candidate) const attributes = connected ? connectedIdentityAttributes : stableAttributes const genericName = @@ -976,7 +1068,13 @@ export function collectSnapshot( return true } - const current = registry[id] + const current = registry[id] ?? issued?.element.deref() + // An earlier ref whose element has since been issued a new ref (it changed + // identity in between, e.g. a recycled row) no longer names it. + if (!inLatestSnapshot && current && refOfElement.get(current) !== id) { + window.__simAgentStaleReason = 'the element was given a new ref by a later snapshot' + return null + } if (current?.isConnected) { if (!identityMatches(current, true)) { window.__simAgentStaleReason = `the node is still mounted but its identity drifted (now <${String(current.tagName || '').toLowerCase()}> role=${roleFor(current) || 'none'} name="${nameFor(current).slice(0, 60)}")` @@ -988,7 +1086,7 @@ export function collectSnapshot( } } - if (!allowRecovery) { + if (!allowRecovery || !inLatestSnapshot) { window.__simAgentStaleReason = 'the original snapshot node is detached or hidden' return null } @@ -1307,6 +1405,8 @@ export function clickElement( let clientY = 0 let blocker: Element | null = null const blockedHits: Array = [] + let firstBlockedPoint: { x: number; y: number } | null = null + let filledByControl: Element | null = null let foundPoint = false const blockerLabel = (element: Element | null): string => ( @@ -1334,6 +1434,7 @@ export function clickElement( break } blocker ??= hit + firstBlockedPoint ??= { x, y } blockedHits.push(hit) } if (foundPoint) break @@ -1436,21 +1537,59 @@ export function clickElement( break } } - if (nested) { - let control = blocker + const nestedControlOf = (hit: Element | null): Element | null => { + let control = hit while (control && control !== el && !isIndependentInteractive(control)) { control = composedParent(control) } + return control && control !== el ? control : null + } + const control = nested ? nestedControlOf(blocker) : null + // One nested control filling the whole target (a time-slot label inside + // its link) is what a person clicking the target hits, so a native click + // on the top page goes there. Form fields and disabled controls keep + // their own refusals, and any uncovered edge leaves the target clickable + // in its own right, so both refuse. + const fillsTarget = (node: Element): boolean => { + const bounds = node.getBoundingClientRect() + return rects.every( + (rect) => + bounds.left <= rect.left + 1 && + bounds.top <= rect.top + 1 && + bounds.right >= rect.right - 1 && + bounds.bottom >= rect.bottom - 1 + ) + } + const controlTag = control ? String(control.tagName || '').toUpperCase() : '' + if ( + control && + firstBlockedPoint && + !dispatchSynthetic && + !focusForKeyboard && + el.ownerDocument.defaultView === window && + window.top === window && + !['INPUT', 'TEXTAREA', 'SELECT'].includes(controlTag) && + !(control as HTMLElement).isContentEditable && + !(control as HTMLButtonElement).disabled && + control.getAttribute('aria-disabled') !== 'true' && + fillsTarget(control) && + blockedHits.every((hit) => hit !== null && nestedControlOf(hit) === control) + ) { + clientX = firstBlockedPoint.x + clientY = firstBlockedPoint.y + filledByControl = control + } else if (nested) { return { error: 'nested-control', blocker: blockerLabel(blocker), - controlId: control && control !== el ? window.__simAgentRefOf(control) : null, + controlId: control ? window.__simAgentRefOf(control) : null, + } + } else { + return { + error: 'obstructed', + blocker: blockerLabel(blocker), + blockerControls: window.__simAgentOverlayControls(blocker), } - } - return { - error: 'obstructed', - blocker: blockerLabel(blocker), - blockerControls: window.__simAgentOverlayControls(blocker), } } @@ -1561,6 +1700,7 @@ export function clickElement( frameDepth, focusSucceeded: focusForKeyboard && el.ownerDocument.activeElement === el, refRecovered: resolved?.recovered === true, + ...(filledByControl ? { clickedThrough: blockerLabel(filledByControl) } : {}), } } @@ -1866,6 +2006,33 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { coveredByRelatedPopup = true } } + // Top-page typing goes through keyboard focus, so a non-modal overlay does not block it; a modal + // or dialog cover still refuses, and framed fields keep the check for their mapped point. + const topPageField = view === window && window.top === window + const insideModal = (node: Element | null): boolean => { + for (let current = node; current; current = composedParent(current)) { + const role = current.getAttribute('role') + if ( + current.getAttribute('aria-modal') === 'true' || + role === 'dialog' || + role === 'alertdialog' || + tagFor(current) === 'DIALOG' + ) { + return true + } + } + return false + } + let coveredBy: string | undefined + if ( + !chosenPoint && + topPageField && + blockedPoints.length > 0 && + !blockedPoints.some((point) => insideModal(point.hit)) + ) { + chosenPoint = { x: blockedPoints[0].x, y: blockedPoints[0].y } + coveredBy = blockerLabel(firstBlocker) + } if (!chosenPoint) { return { error: 'obstructed', @@ -1876,6 +2043,7 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { return { focused: true, + ...(coveredBy ? { coveredBy } : {}), kind: editableTag === 'INPUT' ? 'input' diff --git a/apps/desktop/src/main/browser-agent/post-action-observation.test.ts b/apps/desktop/src/main/browser-agent/post-action-observation.test.ts index 3938df6c7fb..cff8f98322a 100644 --- a/apps/desktop/src/main/browser-agent/post-action-observation.test.ts +++ b/apps/desktop/src/main/browser-agent/post-action-observation.test.ts @@ -2,6 +2,34 @@ import { describe, expect, it, vi } from 'vitest' import { withPostActionObservation } from '@/main/browser-agent/post-action-observation' describe('post-action observation', () => { + it('shows the page after a wait without being asked, since a wait exists to see what changed', async () => { + const result = await withPostActionObservation( + 'browser_wait_for', + { timeoutMs: 500 }, + async (params) => { + expect(params).toEqual({ timeoutMs: 500 }) + return { found: false } + }, + async (query) => { + expect(query).toBeUndefined() + return { outline: '- heading "Order confirmed"' } + }, + vi.fn() + ) + expect(result).toMatchObject({ + found: false, + observation: { ok: true, result: { outline: '- heading "Order confirmed"' } }, + }) + }) + + it('rejects an explicit null observe on a wait instead of treating it as omitted', async () => { + const action = vi.fn(async () => ({ found: true })) + await expect( + withPostActionObservation('browser_wait_for', { observe: null }, action, vi.fn(), vi.fn()) + ).rejects.toThrow('observe must be') + expect(action).not.toHaveBeenCalled() + }) + it('observes after the action, preserving partial form results and replacing refs', async () => { const order: string[] = [] const result = await withPostActionObservation( diff --git a/apps/desktop/src/main/browser-agent/post-action-observation.ts b/apps/desktop/src/main/browser-agent/post-action-observation.ts index 3b2d7554d64..39a6df35d79 100644 --- a/apps/desktop/src/main/browser-agent/post-action-observation.ts +++ b/apps/desktop/src/main/browser-agent/post-action-observation.ts @@ -17,8 +17,15 @@ const OBSERVABLE_ACTIONS: ReadonlySet = new Set([ 'browser_hover', 'browser_drag', 'browser_batch', + 'browser_wait_for', ]) +/** The call's post-action observation request; a wait observes by default, to show what the page became. */ +export function observeRequest(tool: BrowserToolName, params: Record): unknown { + if (params.observe !== undefined) return params.observe + return tool === 'browser_wait_for' ? {} : undefined +} + /** Preserves a dispatched action when its acknowledgement or observation is interrupted. */ export function withFailedPostActionObservation(result: unknown, error: unknown): unknown { const observation = { @@ -38,7 +45,7 @@ export async function withPostActionObservation( observe: (query: string | undefined) => Promise, assertCurrent: () => void ): Promise { - const request = params.observe + const request = observeRequest(tool, params) if (request === undefined) return action(params) if ( !OBSERVABLE_ACTIONS.has(tool) || diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index b4347637bd8..a3320387b6f 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -345,6 +345,7 @@ export const blockTypeToIconMap: Record = { algolia: AlgoliaIcon, algolia_v2: AlgoliaIcon, amplitude: AmplitudeIcon, + amplitude_v2: AmplitudeIcon, api: ApiIcon, api_trigger: ApiIcon, apify: ApifyIcon, @@ -361,6 +362,7 @@ export const blockTypeToIconMap: Record = { athena_v2: AthenaIcon, attio: AttioIcon, azure_data_explorer: AzureDataExplorerIcon, + azure_data_explorer_v2: AzureDataExplorerIcon, azure_devops: AzureIcon, azure_devops_v2: AzureIcon, bitbucket: BitbucketIcon, @@ -379,6 +381,7 @@ export const blockTypeToIconMap: Record = { calendly: CalendlyIcon, calendly_v2: CalendlyIcon, cbinsights: CbInsightsIcon, + cbinsights_v2: CbInsightsIcon, chat_trigger: Task, checkr: CheckrIcon, checkr_v2: CheckrIcon, @@ -568,6 +571,7 @@ export const blockTypeToIconMap: Record = { jupyter_v3: JupyterIcon, kalshi: KalshiIcon, kalshi_v2: KalshiIcon, + kalshi_v3: KalshiIcon, ketch: KetchIcon, knowledge: PackageSearchIcon, lambda: LambdaIcon, @@ -626,7 +630,9 @@ export const blockTypeToIconMap: Record = { mongodb: MongoDBIcon, mothership: Blimp, mssql: MicrosoftSqlIcon, + mssql_v2: MicrosoftSqlIcon, mysql: MySQLIcon, + mysql_v2: MySQLIcon, neo4j: Neo4jIcon, netsuite: NetSuiteIcon, neverbounce: NeverBounceIcon, @@ -664,6 +670,7 @@ export const blockTypeToIconMap: Record = { planetscale_v2: PlanetScaleIcon, polymarket: PolymarketIcon, postgresql: PostgresIcon, + postgresql_v2: PostgresIcon, posthog: PosthogIcon, powerbi: PowerBIIcon, profound: ProfoundIcon, @@ -673,6 +680,7 @@ export const blockTypeToIconMap: Record = { pulse_v2: PulseIcon, pulse_v3: PulseIcon, qdrant: QdrantIcon, + qdrant_v2: QdrantIcon, quartr: QuartrIcon, quartr_v2: QuartrIcon, quickbooks: QuickBooksIcon, @@ -681,6 +689,7 @@ export const blockTypeToIconMap: Record = { quiver_v3: QuiverIcon, rabbitmq: RabbitmqIcon, railway: RailwayIcon, + railway_v2: RailwayIcon, ramp: RampIcon, rb2b: RB2BIcon, rb2b_v2: RB2BIcon, @@ -745,6 +754,7 @@ export const blockTypeToIconMap: Record = { smtp: SmtpIcon, snowflake: SnowflakeIcon, splunk: SplunkIcon, + splunk_v2: SplunkIcon, sportmonks: SportmonksIcon, sportmonks_v2: SportmonksIcon, sqs: SQSIcon, diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index 4a516f7eb40..57dc0db372e 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -93,6 +93,8 @@ Keyless (workload identity) credentials take two steps. Step 1: run `sim credentials keyless-subject --output json` and configure your cloud to trust the printed issuer, audience, and subject. Its requiredConfigFields lists the values your provider's keylessConfig still needs. +The printed audience is the default. To have Sim sign tokens for your own audience instead, set audience in keylessConfig and trust that value. Google Cloud prints no audience: its default is the credential's own workload identity pool provider. + Step 2: add those values to keylessConfig and pass it to `sim credentials create --credentials @file`. The proof expires after 24 hours and works only for the user who requested it, with the same providerId. If creation reports WORKLOAD_IDENTITY_PROOF_EXPIRED, WORKLOAD_IDENTITY_PROOF_INVALID, WORKLOAD_IDENTITY_SUBJECT_REQUIRED, WORKLOAD_IDENTITY_SUBJECT_IN_USE, run step 1 again, trust the new subject, and retry. @@ -366,6 +368,8 @@ Keyless (workload identity) credentials take two steps. Step 1: run `sim credentials keyless-subject --output json` and configure your cloud to trust the printed issuer, audience, and subject. Its requiredConfigFields lists the values your provider's keylessConfig still needs. +The printed audience is the default. To have Sim sign tokens for your own audience instead, set audience in keylessConfig and trust that value. Google Cloud prints no audience: its default is the credential's own workload identity pool provider. + Step 2: add those values to keylessConfig and pass it to `sim credentials create --credentials @file`. The proof expires after 24 hours and works only for the user who requested it, with the same providerId. If creation reports WORKLOAD_IDENTITY_PROOF_EXPIRED, WORKLOAD_IDENTITY_PROOF_INVALID, WORKLOAD_IDENTITY_SUBJECT_REQUIRED, WORKLOAD_IDENTITY_SUBJECT_IN_USE, run step 1 again, trust the new subject, and retry. diff --git a/apps/docs/content/docs/cli/knowledge.mdx b/apps/docs/content/docs/cli/knowledge.mdx index b08a06d5396..1a03bb030e4 100644 --- a/apps/docs/content/docs/cli/knowledge.mdx +++ b/apps/docs/content/docs/cli/knowledge.mdx @@ -1040,7 +1040,7 @@ sim knowledge connectors create [options] | Option | Required | Description | | --- | --- | --- | | `--connector-type ` | Yes | Registered connector type. | -| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types); omit it for `apiKey` connectors. | +| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | | `--api-key ` | No | Write-only API key for connector types whose `auth.mode` is `apiKey` (see connector types), or a personal access token for an OAuth connector that also accepts one, such as GitHub. Send it instead of `credentialId`. Pass a raw key, or a secret reference written as the whole value `{{SECRET_NAME}}`, which the server resolves; `$SECRET_NAME` is not a reference. | | `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | | `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. Defaults to 1440. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 0bacce6d88f..5edce25dba9 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -780,6 +780,8 @@ Keyless (workload identity) credentials take two steps. Step 1: run `sim credentials keyless-subject --output json` and configure your cloud to trust the printed issuer, audience, and subject. Its requiredConfigFields lists the values your provider's keylessConfig still needs. +The printed audience is the default. To have Sim sign tokens for your own audience instead, set audience in keylessConfig and trust that value. Google Cloud prints no audience: its default is the credential's own workload identity pool provider. + Step 2: add those values to keylessConfig and pass it to `sim credentials create --credentials @file`. The proof expires after 24 hours and works only for the user who requested it, with the same providerId. If creation reports WORKLOAD_IDENTITY_PROOF_EXPIRED, WORKLOAD_IDENTITY_PROOF_INVALID, WORKLOAD_IDENTITY_SUBJECT_REQUIRED, WORKLOAD_IDENTITY_SUBJECT_IN_USE, run step 1 again, trust the new subject, and retry. @@ -816,6 +818,8 @@ Keyless (workload identity) credentials take two steps. Step 1: run `sim credentials keyless-subject --output json` and configure your cloud to trust the printed issuer, audience, and subject. Its requiredConfigFields lists the values your provider's keylessConfig still needs. +The printed audience is the default. To have Sim sign tokens for your own audience instead, set audience in keylessConfig and trust that value. Google Cloud prints no audience: its default is the credential's own workload identity pool provider. + Step 2: add those values to keylessConfig and pass it to `sim credentials create --credentials @file`. The proof expires after 24 hours and works only for the user who requested it, with the same providerId. If creation reports WORKLOAD_IDENTITY_PROOF_EXPIRED, WORKLOAD_IDENTITY_PROOF_INVALID, WORKLOAD_IDENTITY_SUBJECT_REQUIRED, WORKLOAD_IDENTITY_SUBJECT_IN_USE, run step 1 again, trust the new subject, and retry. @@ -2645,7 +2649,7 @@ sim knowledge connectors create [options] | Option | Required | Description | | --- | --- | --- | | `--connector-type ` | Yes | Registered connector type. | -| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types); omit it for `apiKey` connectors. | +| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | | `--api-key ` | No | Write-only API key for connector types whose `auth.mode` is `apiKey` (see connector types), or a personal access token for an OAuth connector that also accepts one, such as GitHub. Send it instead of `credentialId`. Pass a raw key, or a secret reference written as the whole value `{{SECRET_NAME}}`, which the server resolves; `$SECRET_NAME` is not a reference. | | `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | | `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. Defaults to 1440. | diff --git a/apps/docs/content/docs/integrations/amplitude.mdx b/apps/docs/content/docs/integrations/amplitude.mdx index 27ee2e9fd33..51cffa5efad 100644 --- a/apps/docs/content/docs/integrations/amplitude.mdx +++ b/apps/docs/content/docs/integrations/amplitude.mdx @@ -2,7 +2,7 @@ title: Amplitude description: Track events and query analytics from Amplitude integration: - type: "amplitude" + type: "amplitude_v2" color: "#13294B" --- @@ -11,6 +11,8 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Amplitude](https://amplitude.com/) to send events, update user and group properties, and query product analytics from a workflow. The HTTP and Dashboard REST APIs use API-key and secret-key authentication. + +Connect an Amplitude project once as an Amplitude credential: in Amplitude, open **Settings > Projects**, choose the project, and copy the **API key** and **secret key** from its **General** tab. Enter `eu` as the data residency only for a project hosted in Amplitude's EU data center. Sim checks the pair by listing the project's events, a read with no billing cost. Event and identify operations send the API key, analytics operations and **User Profile** send both keys; User Profile is not available for EU projects. Workflows built with the earlier Amplitude block keep the keys they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -29,7 +31,6 @@ Track an event in Amplitude using the HTTP V2 API. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | | `userId` | string | No | User ID \(required if no device_id\) | | `deviceId` | string | No | Device ID \(required if no user_id\) | | `eventType` | string | Yes | Name of the event \(e.g., "page_view", "purchase"\) | @@ -48,7 +49,6 @@ Track an event in Amplitude using the HTTP V2 API. | `revenue` | string | No | Revenue amount | | `productId` | string | No | Product identifier | | `revenueType` | string | No | Revenue type \(e.g., "purchase", "refund"\) | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -71,11 +71,9 @@ Set user properties in Amplitude using the Identify API. Supports $set, $setOnce | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | | `userId` | string | No | User ID \(required if no device_id\) | | `deviceId` | string | No | Device ID \(required if no user_id\) | | `userProperties` | string | Yes | JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset. | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -96,11 +94,9 @@ Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | | `groupType` | string | Yes | Group classification \(e.g., "company", "org_id"\) | | `groupValue` | string | Yes | Specific group identifier \(e.g., "Acme Corp"\) | | `groupProperties` | string | Yes | JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset. | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -121,10 +117,7 @@ Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard RES | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | -| `secretKey` | string | Yes | Amplitude Secret Key | | `user` | string | Yes | User ID, Device ID, or Amplitude ID to search for | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -147,13 +140,10 @@ Get the event stream for a specific user by their Amplitude ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | -| `secretKey` | string | Yes | Amplitude Secret Key | | `amplitudeId` | string | Yes | Amplitude internal user ID | | `offset` | string | No | Offset for pagination \(default 0\) | | `limit` | string | No | Maximum number of events to return \(default 1000, max 1000\) | | `direction` | string | No | Sort direction: "latest" or "earliest" \(default: latest\) | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -190,7 +180,6 @@ Get a user profile including properties, cohort memberships, and computed proper | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `secretKey` | string | Yes | Amplitude Secret Key | | `userId` | string | No | External user ID \(required if no device_id\) | | `deviceId` | string | No | Device ID \(required if no user_id\) | | `getAmpProps` | string | No | Include Amplitude user properties \(true/false, default: false\) | @@ -219,8 +208,6 @@ Query event analytics data with segmentation. Get event counts, uniques, average | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | -| `secretKey` | string | Yes | Amplitude Secret Key | | `eventType` | string | Yes | Event type name to analyze | | `start` | string | Yes | Start date in YYYYMMDD format | | `end` | string | Yes | End date in YYYYMMDD format | @@ -232,7 +219,6 @@ Query event analytics data with segmentation. Get event counts, uniques, average | `filters` | string | No | 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` | string | No | Required when metric is "formula", e.g. "UNIQUES\(A\)/UNIQUES\(B\)" | | `segment` | string | No | JSON segment definition\(s\) applied to the query | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -255,15 +241,12 @@ Get active or new user counts over a date range from the Dashboard REST API. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | -| `secretKey` | string | Yes | Amplitude Secret Key | | `start` | string | Yes | Start date in YYYYMMDD format | | `end` | string | Yes | End date in YYYYMMDD format | | `metric` | string | No | Metric type: "active" or "new" \(default: active\) | | `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | | `groupBy` | string | No | Property name to group by | | `segment` | string | No | JSON segment definition\(s\) applied to the query | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -285,9 +268,6 @@ Get real-time active user counts at 5-minute granularity for the last 2 days. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | -| `secretKey` | string | Yes | Amplitude Secret Key | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -309,9 +289,6 @@ List all event types in the Amplitude project with their weekly totals and uniqu | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | -| `secretKey` | string | Yes | Amplitude Secret Key | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -338,15 +315,12 @@ Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user count | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | -| `secretKey` | string | Yes | Amplitude Secret Key | | `start` | string | Yes | Start date in YYYYMMDD format | | `end` | string | Yes | End date in YYYYMMDD format | | `metric` | string | No | Metric: 0 \(ARPU\), 1 \(ARPPU\), 2 \(Total Revenue\), 3 \(Paying Users\) | | `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | | `groupBy` | string | No | Property name to group by \(limit: one\) | | `segment` | string | No | JSON segment definition\(s\) applied to the query | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -369,8 +343,6 @@ Analyze conversion rates and drop-off between a sequence of events. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | -| `secretKey` | string | Yes | Amplitude Secret Key | | `events` | string | Yes | JSON array of event objects, one per funnel step in order, e.g. \[\{"event_type":"signup"\},\{"event_type":"purchase"\}\] | | `start` | string | Yes | Start date in YYYYMMDD format | | `end` | string | Yes | End date in YYYYMMDD format | @@ -381,7 +353,6 @@ Analyze conversion rates and drop-off between a sequence of events. | `groupBy` | string | No | Property to group by \(limit: one; prefix custom properties with "gp:"\) | | `limit` | string | No | Maximum number of group-by values \(default: 100, max: 1000\) | | `segment` | string | No | JSON segment definition\(s\) applied to the query | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -408,8 +379,6 @@ Measure how many users return to perform an action after a starting action. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Amplitude API Key | -| `secretKey` | string | Yes | Amplitude Secret Key | | `startEvent` | string | Yes | JSON starting event object, e.g. \{"event_type":"_new"\} or \{"event_type":"_active"\} | | `returnEvent` | string | Yes | JSON returning event object, e.g. \{"event_type":"_all"\} or \{"event_type":"_active"\} | | `start` | string | Yes | Start date in YYYYMMDD format | @@ -419,7 +388,6 @@ Measure how many users return to perform an action after a starting action. | `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | | `groupBy` | string | No | Property to group by \(limit: one; prefix custom properties with "gp:"\) | | `segment` | string | No | JSON segment definition\(s\) applied to the query | -| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output diff --git a/apps/docs/content/docs/integrations/apollo.mdx b/apps/docs/content/docs/integrations/apollo.mdx index d932a6c37bf..4031fd77e5f 100644 --- a/apps/docs/content/docs/integrations/apollo.mdx +++ b/apps/docs/content/docs/integrations/apollo.mdx @@ -11,8 +11,50 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Apollo.io](https://apollo.io/) in Sim to search and enrich people and companies, manage CRM contacts and accounts, add contacts to sequences, and create follow-up tasks. Single-record and bulk enrichment operations are documented below. + +Connect an Apollo account with OAuth, or save an API key as an Apollo credential. Existing API-key connections and workflows continue to work. OAuth requires an Apollo administrator or a user with permission to authorize third-party apps. Your Apollo plan, permissions, and credits still apply. + +For self-hosting, register a public partner OAuth app in the [Apollo developer portal](https://developer.apollo.io/), configure `APOLLO_CLIENT_ID` and `APOLLO_CLIENT_SECRET`, and register `https:///api/auth/oauth2/callback/apollo` as a redirect URL. Enable the scopes listed below. API-key credentials remain available without an OAuth client. {/* MANUAL-CONTENT-END */} +## OAuth scopes [#oauth-scopes] + +Sim requests these scopes when someone connects an Apollo account. For a self-hosted deployment, register your own app with the provider using the settings below. See [Integrations and OAuth](/platform/self-hosting/integrations-oauth) for setup. + +| Setting | Value | +| ------- | ----- | +| Redirect URI | `/api/auth/oauth2/callback/apollo` | +| Environment variables | `APOLLO_CLIENT_ID`, `APOLLO_CLIENT_SECRET` | + +| Scope | Description | +| ----- | ----------- | +| `read_user_profile` | View your Apollo profile | +| `mixed_people_api_search` | Search Apollo people | +| `people_match` | Enrich a person in Apollo | +| `people_bulk_match` | Enrich people in bulk in Apollo | +| `mixed_companies_search` | Search Apollo companies | +| `organizations_enrich` | Enrich an Apollo organization | +| `organizations_bulk_enrich` | Enrich organizations in bulk in Apollo | +| `contact_write` | Create Apollo contacts | +| `contact_update` | Update Apollo contacts | +| `contacts_search` | Search Apollo contacts | +| `contacts_bulk_create` | Create Apollo contacts in bulk | +| `contacts_bulk_update` | Update Apollo contacts in bulk | +| `account_write` | Create Apollo accounts | +| `account_update` | Update Apollo accounts | +| `accounts_search` | Search Apollo accounts | +| `account_bulk_create` | Create Apollo accounts in bulk | +| `account_stages_update` | Update Apollo account stages | +| `opportunity_write` | Create Apollo deals | +| `opportunities_list` | List Apollo deals | +| `opportunity_read` | View Apollo deals | +| `opportunity_update` | Update Apollo deals | +| `emailer_campaigns_search` | Search Apollo sequences | +| `emailer_campaigns_add_contact_ids` | Add contacts to Apollo sequences | +| `tasks_create` | Create Apollo tasks | +| `tasks_list` | Search Apollo tasks | +| `email_accounts_list` | View Apollo email accounts | + ## Use the block [#usage-instructions] Search for people and companies, enrich contact data, manage your CRM contacts and accounts, add contacts to sequences, and create tasks. diff --git a/apps/docs/content/docs/integrations/athena.mdx b/apps/docs/content/docs/integrations/athena.mdx index 23663e485cd..cd1d60fcf46 100644 --- a/apps/docs/content/docs/integrations/athena.mdx +++ b/apps/docs/content/docs/integrations/athena.mdx @@ -38,6 +38,10 @@ Start an SQL query execution in AWS Athena | `executionParameters` | json | No | Values for ? placeholders in a parameterized query or EXECUTE statement, applied in order. Pass a JSON array of strings \(e.g. \["2024-01-01", "US"\]\); a plain comma-separated list is also accepted when no value contains a comma | | `resultReuseEnabled` | boolean | No | Reuse a previous result of the same query instead of re-scanning data \(default: false\) | | `resultReuseMaxAgeInMinutes` | number | No | Maximum age in minutes of a previous result eligible for reuse \(0-10080, default: 60\) | +| `encryptionOption` | string | No | Encryption for the query results in S3: SSE_S3, SSE_KMS, or CSE_KMS. Overridden when the workgroup enforces its own settings | +| `kmsKey` | string | No | KMS key ARN or ID used when encryptionOption is SSE_KMS or CSE_KMS | +| `expectedBucketOwner` | string | No | AWS account ID expected to own the output location bucket; the query fails if it does not | +| `clientRequestToken` | string | No | Idempotency token \(32-128 characters\). Repeating a request with the same token returns the original query execution instead of starting a new one | #### Output @@ -80,6 +84,14 @@ Get the status and details of an Athena query execution | `queryQueueTimeInMillis` | number | Time the query spent in queue in milliseconds | | `totalExecutionTimeInMillis` | number | Total execution time in milliseconds | | `outputLocation` | string | S3 location of query results | +| `servicePreProcessingTimeInMillis` | number | Time Athena spent preprocessing the query before submitting it, in milliseconds | +| `serviceProcessingTimeInMillis` | number | Time Athena spent finalizing and publishing results after execution, in milliseconds | +| `dataManifestLocation` | string | S3 location of the manifest file listing files written by the query | +| `reusedPreviousResult` | boolean | Whether the query reused a previous result instead of scanning data | +| `athenaError` | json | Failure details for a FAILED query \(errorCategory: 1 system, 2 user, 3 other; errorType; retryable; errorMessage\) | +| `executionParameters` | array | Values supplied for the query's ? placeholders | +| `substatementType` | string | Kind of query statement that was run \(e.g., SELECT, INSERT, CREATE_TABLE\) | +| `engineVersion` | json | Engine version used \(selectedEngineVersion, effectiveEngineVersion\) | @@ -102,10 +114,10 @@ Retrieve the results of a completed Athena query execution | Parameter | Type | Description | | --------- | ---- | ----------- | -| `columns` | array | Column metadata \(name and type\) | +| `columns` | array | Column metadata \(name, type, label, precision, scale, nullable, caseSensitive\) | | `rows` | array | Result rows as key-value objects | | `nextToken` | string | Pagination token for next page of results | -| `updateCount` | number | Number of rows affected \(for INSERT/UPDATE statements\) | +| `updateCount` | number | Number of rows inserted or updated by a CREATE TABLE AS SELECT, INSERT INTO, or UPDATE statement | @@ -188,6 +200,14 @@ Get the status and details of up to 50 Athena query executions in one call | ↳ `queryQueueTimeInMillis` | number | Time the query spent in queue in milliseconds | | ↳ `totalExecutionTimeInMillis` | number | Total execution time in milliseconds | | ↳ `outputLocation` | string | S3 location of query results | +| ↳ `servicePreProcessingTimeInMillis` | number | Time Athena spent preprocessing the query before submitting it, in milliseconds | +| ↳ `serviceProcessingTimeInMillis` | number | Time Athena spent finalizing and publishing results after execution, in milliseconds | +| ↳ `dataManifestLocation` | string | S3 location of the manifest file listing files written by the query | +| ↳ `reusedPreviousResult` | boolean | Whether the query reused a previous result instead of scanning data | +| ↳ `athenaError` | json | Failure details for a FAILED query \(errorCategory: 1 system, 2 user, 3 other; errorType; retryable; errorMessage\) | +| ↳ `executionParameters` | array | Values supplied for the query's ? placeholders | +| ↳ `substatementType` | string | Kind of query statement that was run \(e.g., SELECT, INSERT, CREATE_TABLE\) | +| ↳ `engineVersion` | json | Engine version used \(selectedEngineVersion, effectiveEngineVersion\) | | `unprocessedQueryExecutionIds` | array | Query execution IDs that could not be retrieved, with error details | | ↳ `queryExecutionId` | string | Query execution ID | | ↳ `errorCode` | string | Error code | diff --git a/apps/docs/content/docs/integrations/aws-credentials.mdx b/apps/docs/content/docs/integrations/aws-credentials.mdx index ede81c9503d..9a050eb04a0 100644 --- a/apps/docs/content/docs/integrations/aws-credentials.mdx +++ b/apps/docs/content/docs/integrations/aws-credentials.mdx @@ -6,7 +6,7 @@ description: Save an AWS identity once and use it across every AWS block, with a import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -An AWS credential holds one AWS identity for the whole workspace. Every AWS block (S3, DynamoDB, Lambda, SQS, Athena, CloudWatch, and the rest) signs in with a saved credential, so keys live in one place instead of in each block. +An AWS credential holds one AWS identity for the whole workspace. Every AWS block (S3, DynamoDB, Lambda, SQS, Athena, CloudWatch, and the rest) signs in with a saved credential, so keys live in one place instead of in each block. The Agent, Router, and Evaluator blocks can also run AWS Bedrock models with a saved credential: choose **Saved AWS credential** under **AWS Authentication**. Blocks added before saved credentials, which take access keys typed into the block, are marked legacy and keep working exactly as they are. Upgrade one by replacing it with the current block and selecting a credential. @@ -46,13 +46,27 @@ STS **Get Session Token** works only with an **Access keys** credential: AWS ref +### Use your own audience [#keyless-audience] + +Tokens Sim signs for a keyless role carry the audience `sts.amazonaws.com` by default. To have your account accept only tokens issued for it, enter your own **Audience** in the dialog (for example your AWS account ID): up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`. The **Role trust policy** then uses it in its `aud` condition. + +AWS accepts a token only when its audience is one of the identity provider's audiences (client IDs), so add yours to the provider. In the IAM console, open the provider and choose **Add audience**, or run: + +```bash +aws iam add-client-id-to-open-id-connect-provider \ + --open-id-connect-provider-arn arn:aws:iam:::oidc-provider/ \ + --client-id '' +``` + +A provider holds up to 100 audiences. Once no role relies on `sts.amazonaws.com` any more, you can remove it from the provider. Reconnecting the credential keeps its audience; clearing the field returns it to the default. + The subject is unique to the credential. Reconnecting a keyless credential keeps its subject by default, so the role's trust policy keeps working; choose **Issue a new subject** only if you also update the trust policy. ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject aws-service-account`, trust the printed subject as above, then add `roleArn` (optionally `stsRegion` and `sessionDurationSeconds`) to its `keylessConfig` and pass it to `sim credentials create aws-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. +Run `sim credentials keyless-subject aws-service-account`, trust the printed subject as above, then add `roleArn` (optionally `stsRegion`, `sessionDurationSeconds`, and `audience`) to its `keylessConfig` and pass it to `sim credentials create aws-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. ## Use access keys @@ -80,6 +94,6 @@ Without a signing key, the keyless option is unavailable and access keys work as ## Troubleshooting -- **`InvalidIdentityToken` or "No OpenIDConnect provider found":** the IAM identity provider's URL or audience doesn't match the dialog exactly, or AWS can't reach the issuer. Open `/.well-known/openid-configuration` from outside your network; it must load over `https`. +- **`InvalidIdentityToken` or "No OpenIDConnect provider found":** the IAM identity provider's URL doesn't match the dialog exactly, its audiences don't include the credential's audience, or AWS can't reach the issuer. Open `/.well-known/openid-configuration` from outside your network; it must load over `https`. - **"Sim could not assume this role: Not authorized to perform sts:AssumeRoleWithWebIdentity":** the role's trust policy doesn't trust this credential's subject. Paste the trust policy from the dialog again; if you chose **Issue a new subject**, update the policy to the new one. - **"This role trusts every Sim subject":** the trust policy is missing the `sub` condition. Use the policy from the dialog. diff --git a/apps/docs/content/docs/integrations/azure_data_explorer.mdx b/apps/docs/content/docs/integrations/azure_data_explorer.mdx index a35103f1711..397c290913d 100644 --- a/apps/docs/content/docs/integrations/azure_data_explorer.mdx +++ b/apps/docs/content/docs/integrations/azure_data_explorer.mdx @@ -2,7 +2,7 @@ title: Azure Data Explorer description: Query and manage Azure Data Explorer (Kusto) clusters with KQL integration: - type: "azure_data_explorer" + type: "azure_data_explorer_v2" color: "#FFFFFF" --- @@ -32,7 +32,7 @@ In Sim, this integration gives your agents both halves: - **Debug the pipeline** — list ingestion failures with their error codes and root causes, and check the state of a long-running operation - **Run any management command** — the escape hatch for policies, mappings, and anything else on the control plane -Authentication uses a **Microsoft Entra service principal** (an app registration with a tenant ID, client ID, and client secret) rather than an interactive sign-in, so scheduled and unattended workflows keep working without anyone logging in. Grant that principal access to the database with `.add database viewers ('aadapp=;')` — use `viewers` for read-only agents, and `ingestors` or `users` only when a workflow needs to write. +Authentication uses a **Microsoft Entra app registration** rather than an interactive sign-in, so scheduled and unattended workflows keep working without anyone logging in. Select a saved [Microsoft app credential](/integrations/microsoft-app-credentials), which signs in with a client secret, a certificate, or a keyless federated credential; choose the credential's cloud to match the cluster (a `kusto.usgovcloudapi.net` cluster needs a US Government credential, a `kusto.chinacloudapi.cn` one a China credential). Sim mints a token for the cluster itself (`https://`) on each run. Grant the app access to the database with `.add database viewers ('aadapp=;')` — use `viewers` for read-only agents, and `ingestors` or `users` only when a workflow needs to write. Workflows built before saved credentials keep their inline tenant ID, client ID, and client secret. A few things worth knowing before you build: @@ -46,13 +46,13 @@ A few things worth knowing before you build: ## Use the block [#usage-instructions] -Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Authenticates with a Microsoft Entra service principal using client credentials, so no interactive sign-in is needed. +Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Runs as a Microsoft app credential: an app registration signing in with a client secret, a certificate, or a keyless federated credential, in the global cloud or a national cloud, so no interactive sign-in is needed. ## Actions ### Azure Data Explorer Query -Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table. +Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table (plus any further tables a batch or fork produces). @@ -61,9 +61,6 @@ Run a Kusto Query Language (KQL) query against an Azure Data Explorer database a | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database to run the query against | | `query` | string | Yes | KQL query text \(e.g., StormEvents \| where State == "FLORIDA" \| summarize count\(\) by EventType\) | @@ -83,7 +80,18 @@ Run a Kusto Query Language (KQL) query against an Azure Data Explorer database a | `records` | array | Result rows keyed by column name | | `rowCount` | number | Rows carried in this result, after the row cap | | `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | -| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | +| `truncated` | boolean | Whether rows of the primary result table were dropped to stay within the row or size cap \(each additionalResults entry has its own truncated flag\) — narrow the query if true | +| `additionalResults` | array | Further result tables, present only when a query batch \(statements separated by ;\) or fork returns more than one; the row and size caps are shared across all tables | +| ↳ `tableName` | string | Result table name | +| ↳ `columns` | array | Column metadata | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| ↳ `rows` | array | Rows as positional arrays matching the columns order | +| ↳ `records` | array | Rows keyed by column name | +| ↳ `rowCount` | number | Rows carried after the caps | +| ↳ `totalRowCount` | number | Rows Kusto returned | +| ↳ `truncated` | boolean | Whether rows were dropped by the caps | @@ -98,9 +106,6 @@ Run an Azure Data Explorer management command (a control command starting with " | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `command` | string | Yes | Management command text, starting with "." \(e.g., .show table Events details\) | | `database` | string | No | Database context for the command. Required for all commands except cluster-level ones such as .show databases | @@ -133,9 +138,6 @@ List the databases on an Azure Data Explorer cluster that the service principal | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | #### Output @@ -167,9 +169,6 @@ List the tables in an Azure Data Explorer database, with their folder and docstr | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database whose tables should be listed | @@ -202,9 +201,6 @@ Read the column schema of an Azure Data Explorer table in CSL form (e.g., "Times | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database containing the table | | `table` | string | Yes | Table whose schema should be read | @@ -232,9 +228,6 @@ Read the full schema of an Azure Data Explorer database as a flat list of every | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database whose schema should be read | @@ -266,9 +259,6 @@ Read size, row count, hot-cache footprint, and effective policies for a table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database to read table details from | | `table` | string | No | Table to describe. Omit to describe every table in the database | @@ -301,9 +291,6 @@ List the stored functions in an Azure Data Explorer database, with their paramet | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database whose stored functions should be listed | @@ -336,9 +323,6 @@ Push rows directly into an Azure Data Explorer table with .ingest inline. Data i | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database containing the target table | | `table` | string | Yes | Table to ingest into. Its schema is the assumed schema for the data | @@ -374,9 +358,6 @@ Materialize the result of a KQL query into a table with .set, .append, .set-or-a | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database containing the target table | | `table` | string | Yes | Table to ingest the query result into | @@ -413,9 +394,6 @@ Create a table in an Azure Data Explorer database from a CSL column schema. Succ | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database to create the table in | | `table` | string | Yes | Name of the table to create | @@ -450,9 +428,6 @@ Drop a table from an Azure Data Explorer database. The table becomes unavailable | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database containing the table | | `table` | string | Yes | Name of the table to drop | @@ -487,9 +462,6 @@ List ingestion failures recorded for a database, with the failing table, error c | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | Yes | Database whose ingestion failures should be listed | | `operationId` | string | No | Limit results to a single ingestion operation ID | @@ -522,9 +494,6 @@ Check the state of administrative operations on a cluster, such as an async inge | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net) | -| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | -| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | -| `clientSecret` | string | Yes | Microsoft Entra application client secret | | `resource` | string | No | Token audience override. Defaults to the cluster URI itself | | `database` | string | No | Database context for the command | | `operationId` | string | No | Operation ID to check, e.g. the ID returned by an async ingestion | diff --git a/apps/docs/content/docs/integrations/azure_devops.mdx b/apps/docs/content/docs/integrations/azure_devops.mdx index f376227d22d..4b5d76252f5 100644 --- a/apps/docs/content/docs/integrations/azure_devops.mdx +++ b/apps/docs/content/docs/integrations/azure_devops.mdx @@ -35,7 +35,7 @@ List all pipelines in an Azure DevOps project. Returns pipeline ID, name, folder | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `project` | string | Yes | Azure DevOps project name | -| `orderBy` | string | No | Field to sort results by \(e.g. "name"\) | +| `orderBy` | string | No | Sort expression \(defaults to "name asc"\) | | `top` | number | No | Maximum number of pipelines to return | | `continuationToken` | string | No | Continuation token for paginating results | @@ -46,6 +46,7 @@ List all pipelines in an Azure DevOps project. Returns pipeline ID, name, folder | `content` | string | Human-readable summary of pipelines | | `metadata` | object | Pipelines metadata | | ↳ `count` | number | Total number of pipelines returned | +| ↳ `continuationToken` | string | Token for the next page \(from the x-ms-continuationtoken header\); pass it back as continuationToken. Null on the last page. | | ↳ `pipelines` | array | Array of pipeline objects | | ↳ `id` | number | Pipeline ID | | ↳ `name` | string | Pipeline name | @@ -165,7 +166,7 @@ Get details for a specific pipeline run in an Azure DevOps project. Returns run ### Azure DevOps List Builds -List builds in an Azure DevOps project. Optionally filter by pipeline definition, status, result, or branch. +List builds in an Azure DevOps project. Optionally filter by pipeline definition, status, result, reason, requester, branch, or time range, and page with a continuation token. @@ -179,6 +180,12 @@ List builds in an Azure DevOps project. Optionally filter by pipeline definition | `statusFilter` | string | No | Filter by build status: inProgress, completed, cancelling, postponed, notStarted, none | | `resultFilter` | string | No | Filter by build result: succeeded, partiallySucceeded, failed, canceled | | `branchName` | string | No | Filter by source branch name \(e.g. "refs/heads/main"\) | +| `reasonFilter` | string | No | Filter by build reason: manual, individualCI, batchedCI, schedule, pullRequest, buildCompletion, resourceTrigger | +| `requestedFor` | string | No | Filter to builds requested for this user \(email or ID\) | +| `minTime` | string | No | ISO 8601 date-time; only builds that finished/started/queued after it \(per queryOrder\) | +| `maxTime` | string | No | ISO 8601 date-time; only builds that finished/started/queued before it \(per queryOrder\) | +| `queryOrder` | string | No | Sort order: queueTimeDescending, queueTimeAscending, startTimeDescending, startTimeAscending, finishTimeDescending, finishTimeAscending | +| `continuationToken` | string | No | Continuation token from a previous List Builds call, to fetch the next page | #### Output @@ -187,6 +194,7 @@ List builds in an Azure DevOps project. Optionally filter by pipeline definition | `content` | string | Human-readable summary of builds | | `metadata` | object | Builds metadata | | ↳ `count` | number | Total number of builds returned | +| ↳ `continuationToken` | string | Token for the next page \(from the x-ms-continuationtoken header\); pass it back as continuationToken. Null on the last page. | | ↳ `builds` | array | Array of build objects | | ↳ `id` | number | Build ID | | ↳ `buildNumber` | string | Build number \(e.g. "20210601.1"\) | @@ -201,6 +209,9 @@ List builds in an Azure DevOps project. Optionally filter by pipeline definition | ↳ `id` | number | Definition ID | | ↳ `name` | string | Definition name | | ↳ `webUrl` | string | Browser URL for the build | +| ↳ `reason` | string | Why the build was created \(e.g. manual, individualCI, pullRequest\) | +| ↳ `requestedFor` | string | Display name of the user the build was queued for | +| ↳ `tags` | array | Build tags | @@ -291,6 +302,12 @@ Get the execution timeline for an Azure DevOps build — every stage, job, and t | ↳ `warningCount` | number | Number of warnings | | ↳ `startTime` | string | ISO 8601 start timestamp | | ↳ `finishTime` | string | ISO 8601 finish timestamp | +| ↳ `parentId` | string | GUID of the parent record \(job for a task, stage for a job\) | +| ↳ `state` | string | pending \| inProgress \| completed | +| ↳ `issues` | array | Errors and warnings the step reported \(type error\|warning, category, message\) | +| ↳ `type` | string | error or warning | +| ↳ `category` | string | Issue category | +| ↳ `message` | string | Issue message | | ↳ `failedRecords` | array | Subset of records where result is failed, partiallySucceeded, or succeededWithIssues — use logId to fetch logs | | ↳ `id` | string | Record GUID | | ↳ `name` | string | Step name | @@ -301,6 +318,12 @@ Get the execution timeline for an Azure DevOps build — every stage, job, and t | ↳ `warningCount` | number | Number of warnings | | ↳ `startTime` | string | ISO 8601 start timestamp | | ↳ `finishTime` | string | ISO 8601 finish timestamp | +| ↳ `parentId` | string | GUID of the parent record \(job for a task, stage for a job\) | +| ↳ `state` | string | pending \| inProgress \| completed | +| ↳ `issues` | array | Errors and warnings the step reported \(type error\|warning, category, message\) | +| ↳ `type` | string | error or warning | +| ↳ `category` | string | Issue category | +| ↳ `message` | string | Issue message | @@ -333,7 +356,7 @@ Get work item references associated with commits between two builds in Azure Dev ### Azure DevOps Query Work Items -Execute a WIQL query to search for work items in Azure DevOps and return full field details. Use TOP N in your query to limit results (Azure enforces a 200-item maximum per batch fetch). +Execute a WIQL query to search for work items in Azure DevOps and return full field details. Supports flat queries and link queries (FROM WorkItemLinks). Use the top parameter to limit results. @@ -342,7 +365,8 @@ Execute a WIQL query to search for work items in Azure DevOps and return full fi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `project` | string | Yes | Azure DevOps project name | -| `wiqlQuery` | string | Yes | WIQL query string \(e.g. "SELECT \[System.Id\] FROM workitems WHERE \[System.State\] = 'Doing' ORDER BY \[System.Id\] ASC"\). Use TOP N to limit results. | +| `wiqlQuery` | string | Yes | WIQL query string \(e.g. "SELECT \[System.Id\] FROM workitems WHERE \[System.State\] = 'Doing' ORDER BY \[System.Id\] ASC"\). Link queries \(FROM WorkItemLinks\) are supported. | +| `top` | number | No | Maximum number of work items the query returns \(WIQL $top\). Each matched item is fetched with full details, so set this for broad queries. | #### Output @@ -352,6 +376,10 @@ Execute a WIQL query to search for work items in Azure DevOps and return full fi | `metadata` | object | Work items metadata | | ↳ `count` | number | Number of work items returned \(after hydration\) | | ↳ `totalMatched` | number | Total number of work items matched by the WIQL query before hydration | +| ↳ `workItemRelations` | array | Links returned by a link query \(FROM WorkItemLinks\); source and target items are included in workItems | +| ↳ `rel` | string | Link type, or null for a root item | +| ↳ `sourceId` | number | Source work item ID | +| ↳ `targetId` | number | Target work item ID | | ↳ `workItems` | array | Array of work item details | | ↳ `id` | number | Work item ID | | ↳ `title` | string | Work item title | @@ -360,6 +388,18 @@ Execute a WIQL query to search for work items in Azure DevOps and return full fi | ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | | ↳ `areaPath` | string | Area path of the work item | | ↳ `url` | string | API URL for the work item | +| ↳ `rev` | number | Revision number of the work item | +| ↳ `description` | string | Description \(System.Description, HTML\) | +| ↳ `iterationPath` | string | Iteration path \(System.IterationPath\) | +| ↳ `reason` | string | Reason for the current state \(System.Reason\) | +| ↳ `tags` | string | Semicolon-separated tags \(System.Tags\) | +| ↳ `priority` | number | Priority \(Microsoft.VSTS.Common.Priority\) | +| ↳ `assignedToUniqueName` | string | Unique name \(usually the email\) of the assigned user | +| ↳ `createdBy` | string | Display name of the creator | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `changedBy` | string | Display name of the last user to change the work item | +| ↳ `changedDate` | string | ISO 8601 last-changed timestamp | +| ↳ `webUrl` | string | Browser URL for the work item | @@ -390,6 +430,23 @@ Fetch full details of a single work item by ID from Azure DevOps, including titl | ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | | ↳ `areaPath` | string | Area path of the work item | | ↳ `url` | string | API URL for the work item | +| ↳ `rev` | number | Revision number of the work item | +| ↳ `description` | string | Description \(System.Description, HTML\) | +| ↳ `iterationPath` | string | Iteration path \(System.IterationPath\) | +| ↳ `reason` | string | Reason for the current state \(System.Reason\) | +| ↳ `tags` | string | Semicolon-separated tags \(System.Tags\) | +| ↳ `priority` | number | Priority \(Microsoft.VSTS.Common.Priority\) | +| ↳ `assignedToUniqueName` | string | Unique name \(usually the email\) of the assigned user | +| ↳ `createdBy` | string | Display name of the creator | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `changedBy` | string | Display name of the last user to change the work item | +| ↳ `changedDate` | string | ISO 8601 last-changed timestamp | +| ↳ `webUrl` | string | Browser URL for the work item | +| ↳ `relations` | array | Links to other work items, attachments, and artifacts \(rel such as System.LinkTypes.Hierarchy-Forward or AttachedFile, url, attributes\); null when the response did not include relations \(Create and Update do not expand them\) | +| ↳ `rel` | string | Relation type | +| ↳ `url` | string | URL of the linked resource | +| ↳ `attributes` | json | Link attributes \(name, comment, isLocked\) | +| ↳ `fields` | json | Every field returned for the work item, keyed by reference name \(includes custom and process-specific fields\) | @@ -422,6 +479,18 @@ Fetch full details for multiple work items by ID from Azure DevOps. Pass comma-s | ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | | ↳ `areaPath` | string | Area path of the work item | | ↳ `url` | string | API URL for the work item | +| ↳ `rev` | number | Revision number of the work item | +| ↳ `description` | string | Description \(System.Description, HTML\) | +| ↳ `iterationPath` | string | Iteration path \(System.IterationPath\) | +| ↳ `reason` | string | Reason for the current state \(System.Reason\) | +| ↳ `tags` | string | Semicolon-separated tags \(System.Tags\) | +| ↳ `priority` | number | Priority \(Microsoft.VSTS.Common.Priority\) | +| ↳ `assignedToUniqueName` | string | Unique name \(usually the email\) of the assigned user | +| ↳ `createdBy` | string | Display name of the creator | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `changedBy` | string | Display name of the last user to change the work item | +| ↳ `changedDate` | string | ISO 8601 last-changed timestamp | +| ↳ `webUrl` | string | Browser URL for the work item | @@ -465,6 +534,23 @@ Create a new Basic-process work item (Issue, Task, or Epic) in Azure DevOps. Ret | ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | | ↳ `areaPath` | string | Area path of the work item | | ↳ `url` | string | API URL for the created work item | +| ↳ `rev` | number | Revision number of the work item | +| ↳ `description` | string | Description \(System.Description, HTML\) | +| ↳ `iterationPath` | string | Iteration path \(System.IterationPath\) | +| ↳ `reason` | string | Reason for the current state \(System.Reason\) | +| ↳ `tags` | string | Semicolon-separated tags \(System.Tags\) | +| ↳ `priority` | number | Priority \(Microsoft.VSTS.Common.Priority\) | +| ↳ `assignedToUniqueName` | string | Unique name \(usually the email\) of the assigned user | +| ↳ `createdBy` | string | Display name of the creator | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `changedBy` | string | Display name of the last user to change the work item | +| ↳ `changedDate` | string | ISO 8601 last-changed timestamp | +| ↳ `webUrl` | string | Browser URL for the work item | +| ↳ `relations` | array | Links to other work items, attachments, and artifacts \(rel such as System.LinkTypes.Hierarchy-Forward or AttachedFile, url, attributes\); null when the response did not include relations \(Create and Update do not expand them\) | +| ↳ `rel` | string | Relation type | +| ↳ `url` | string | URL of the linked resource | +| ↳ `attributes` | json | Link attributes \(name, comment, isLocked\) | +| ↳ `fields` | json | Every field returned for the work item, keyed by reference name \(includes custom and process-specific fields\) | @@ -485,6 +571,7 @@ Update one or more fields on an existing work item in Azure DevOps. Provide only | `assignedTo` | string | No | Email or display name to reassign the work item to \(optional\) | | `areaPath` | string | No | New area path for the work item \(optional\) | | `priority` | number | No | Priority of the work item \(1 = Critical, 2 = High, 3 = Medium, 4 = Low\) \(optional\) | +| `iterationPath` | string | No | Iteration path to move the work item to \(System.IterationPath\) \(optional\) | | `state` | string | No | New state for Basic-process work items: "To Do", "Doing", or "Done" \(optional\) | | `effort` | number | No | Effort \(Microsoft.VSTS.Scheduling.Effort\). Basic process: Issue only. | | `startDate` | string | No | Start date \(Microsoft.VSTS.Scheduling.StartDate\), ISO 8601. Basic process: Epic only. | @@ -508,6 +595,23 @@ Update one or more fields on an existing work item in Azure DevOps. Provide only | ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | | ↳ `areaPath` | string | Area path of the work item | | ↳ `url` | string | API URL for the work item | +| ↳ `rev` | number | Revision number of the work item | +| ↳ `description` | string | Description \(System.Description, HTML\) | +| ↳ `iterationPath` | string | Iteration path \(System.IterationPath\) | +| ↳ `reason` | string | Reason for the current state \(System.Reason\) | +| ↳ `tags` | string | Semicolon-separated tags \(System.Tags\) | +| ↳ `priority` | number | Priority \(Microsoft.VSTS.Common.Priority\) | +| ↳ `assignedToUniqueName` | string | Unique name \(usually the email\) of the assigned user | +| ↳ `createdBy` | string | Display name of the creator | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `changedBy` | string | Display name of the last user to change the work item | +| ↳ `changedDate` | string | ISO 8601 last-changed timestamp | +| ↳ `webUrl` | string | Browser URL for the work item | +| ↳ `relations` | array | Links to other work items, attachments, and artifacts \(rel such as System.LinkTypes.Hierarchy-Forward or AttachedFile, url, attributes\); null when the response did not include relations \(Create and Update do not expand them\) | +| ↳ `rel` | string | Relation type | +| ↳ `url` | string | URL of the linked resource | +| ↳ `attributes` | json | Link attributes \(name, comment, isLocked\) | +| ↳ `fields` | json | Every field returned for the work item, keyed by reference name \(includes custom and process-specific fields\) | @@ -524,6 +628,7 @@ Add a comment to a work item in Azure DevOps. | `project` | string | Yes | Azure DevOps project name | | `workItemId` | number | Yes | ID of the work item to comment on | | `text` | string | Yes | Comment text \(HTML supported, e.g. "<p>My comment</p>"\) | +| `format` | string | No | Comment format: "markdown" or "html". Omit to send the text without a format \(API version 7.0 behavior\). | #### Output diff --git a/apps/docs/content/docs/integrations/bitbucket.mdx b/apps/docs/content/docs/integrations/bitbucket.mdx index d0712cd7a4e..2739a067b51 100644 --- a/apps/docs/content/docs/integrations/bitbucket.mdx +++ b/apps/docs/content/docs/integrations/bitbucket.mdx @@ -346,6 +346,9 @@ List repository commits in reverse chronological order | --------- | ---- | -------- | ----------- | | `workspaceSlug` | string | Yes | Bitbucket workspace slug or UUID | | `repoSlug` | string | Yes | Bitbucket repository slug or UUID | +| `include` | array | No | Branches, tags, or commit hashes whose history to include; omit to list commits on every ref | +| `exclude` | array | No | Branches, tags, or commit hashes whose reachable commits to leave out | +| `path` | string | No | Only list commits that changed this file or directory | | `nextUrl` | string | No | Opaque next-page URL returned by a previous Bitbucket call | | `pageLen` | number | No | Results per page \(1-100\) | @@ -602,11 +605,15 @@ List pull requests in a Bitbucket Cloud repository | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `destination` | object | Destination endpoint | | ↳ `branchName` | string | Branch name | | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `mergeCommitHash` | string | Merge commit hash | | ↳ `commentCount` | number | Comment count | | ↳ `taskCount` | number | Open task count | @@ -692,11 +699,15 @@ Get a pull request by repository-scoped ID | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `destination` | object | Destination endpoint | | ↳ `branchName` | string | Branch name | | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `mergeCommitHash` | string | Merge commit hash | | ↳ `commentCount` | number | Comment count | | ↳ `taskCount` | number | Open task count | @@ -788,11 +799,15 @@ Create a pull request between repository branches | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `destination` | object | Destination endpoint | | ↳ `branchName` | string | Branch name | | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `mergeCommitHash` | string | Merge commit hash | | ↳ `commentCount` | number | Comment count | | ↳ `taskCount` | number | Open task count | @@ -884,11 +899,15 @@ Start an asynchronous pull request merge and return a task to poll when needed | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `destination` | object | Destination endpoint | | ↳ `branchName` | string | Branch name | | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `mergeCommitHash` | string | Merge commit hash | | ↳ `commentCount` | number | Comment count | | ↳ `taskCount` | number | Open task count | @@ -977,11 +996,15 @@ Poll the status of an asynchronous pull request merge task | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `destination` | object | Destination endpoint | | ↳ `branchName` | string | Branch name | | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `mergeCommitHash` | string | Merge commit hash | | ↳ `commentCount` | number | Comment count | | ↳ `taskCount` | number | Open task count | @@ -1067,11 +1090,15 @@ Decline an open pull request | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `destination` | object | Destination endpoint | | ↳ `branchName` | string | Branch name | | ↳ `commitHash` | string | Commit hash | | ↳ `repositoryUuid` | string | Repository UUID | | ↳ `repositoryFullName` | string | Repository full name | +| ↳ `mergeStrategies` | array | Merge strategies available when this endpoint is the destination | +| ↳ `defaultMergeStrategy` | string | Default merge strategy when this endpoint is the destination | | ↳ `mergeCommitHash` | string | Merge commit hash | | ↳ `commentCount` | number | Comment count | | ↳ `taskCount` | number | Open task count | diff --git a/apps/docs/content/docs/integrations/cbinsights.mdx b/apps/docs/content/docs/integrations/cbinsights.mdx index d32d3ecfda4..14e55c56b98 100644 --- a/apps/docs/content/docs/integrations/cbinsights.mdx +++ b/apps/docs/content/docs/integrations/cbinsights.mdx @@ -2,7 +2,7 @@ title: CB Insights description: Research private markets — firmographics, funding, and predictive scores integration: - type: "cbinsights" + type: "cbinsights_v2" color: "#FFFFFF" --- @@ -14,7 +14,7 @@ import { ReferenceDetails } from "@/components/ui/reference-details" ## Credentials and access -Obtain a client ID and secret from your CB Insights Customer Success Manager. Sim exchanges them for a short-lived bearer token automatically. Most data operations consume credits; access depends on your license for Firmographics, Financial Transactions, Business Relationships, Management and Board, Outlook, or Scouting Reports. Check your entitlements before building a workflow around a dataset. +Obtain a client ID and secret from your CB Insights Customer Success Manager and save them once as a CB Insights credential. Sim checks the pair by exchanging it for an access token, and exchanges it for a short-lived bearer token on each run. Workflows built with the earlier CB Insights block keep the client ID and secret they entered in the block. Most data operations consume credits; access depends on your license for Firmographics, Financial Transactions, Business Relationships, Management and Board, Outlook, or Scouting Reports. Check your entitlements before building a workflow around a dataset. ## Working with company data @@ -43,8 +43,6 @@ Resolve company names or websites to CB Insights organization IDs. The integrati | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `names` | json | No | Organization names to look up, e.g. \["CB Insights"\] | | `urls` | json | No | Organization websites to look up, e.g. \["cbinsights.com"\] | | `profileUrl` | string | No | A CB Insights profile URL to resolve. Mutually exclusive with names and urls — the API rejects a request that sets both. | @@ -72,8 +70,6 @@ Search profiles of private companies, public companies, and investors by market, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `keyword` | string | No | Search term matched against organization names, descriptions, and aliases | | `orgIds` | json | No | CB Insights organization IDs to return, e.g. \[129410, 129411\] | | `orgNames` | json | No | Organization names to match exactly, e.g. \["CB Insights"\] | @@ -133,8 +129,6 @@ Retrieve the funding rounds one organization has received, its cap table history | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | | `limit` | number | No | Rows to return in a single response, 1-100 | | `nextPageToken` | string | No | Continuation token from a previous response; omit for the first page | @@ -161,8 +155,6 @@ Retrieve the rounds in which one organization invested in another, with AI-gener | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | | `limit` | number | No | Rows to return in a single response, 1-100 | | `nextPageToken` | string | No | Continuation token from a previous response; omit for the first page | @@ -188,8 +180,6 @@ Retrieve exit rounds for companies this organization invested in before the exit | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | #### Output @@ -210,8 +200,6 @@ Retrieve one organization's partnerships, client/vendor relationships, and licen | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | #### Output @@ -232,8 +220,6 @@ Retrieve an organization's leadership team and board members with their educatio | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | | `titleIds` | json | No | CB Insights person title IDs to filter the people returned, e.g. \[50, 75\] | @@ -256,8 +242,6 @@ Retrieve an organization's current Mosaic Score, Commercial Maturity level, and | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | #### Output @@ -280,8 +264,6 @@ Retrieve the estimated window in which an organization is likely to raise its ne | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | #### Output @@ -306,8 +288,6 @@ Retrieve reported and estimated revenue by calendar year for one organization, w | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | #### Output @@ -331,8 +311,6 @@ Retrieve an organization's historical Mosaic Scores — overall plus the managem | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | | `startDate` | string | No | Earliest date to return, as YYYY-MM-DD. Must be on or after 2024-01-01 and within the last 24 months. Defaults to the later of one year ago and 2024-01-01. | @@ -358,8 +336,6 @@ Retrieve an organization's historical Commercial Maturity levels, tracking how i | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | | `startDate` | string | No | Earliest date to return, as YYYY-MM-DD. Must be on or after 2024-07-25 and within 24 months of endDate. Defaults to the later of one year ago and 2024-07-25. | | `endDate` | string | No | Latest date to return, as YYYY-MM-DD. Must be on or after 2024-07-25 and within 24 months of startDate. Defaults to today. | @@ -382,8 +358,6 @@ Retrieve an organization's historical two-year IPO and M&A exit probabilities, e | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | | `startDate` | string | No | Earliest date to return, as YYYY-MM-DD. Must be on or after 2025-02-25 and within 24 months of endDate. Defaults to the later of one year ago and 2025-02-25. | | `endDate` | string | No | Latest date to return, as YYYY-MM-DD. Must be on or after 2025-02-25 and within 24 months of startDate. Defaults to today. | @@ -408,8 +382,6 @@ Retrieve the companies related to an organization, grouped into industry categor | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | #### Output @@ -432,8 +404,6 @@ Generate an AI-written Scouting Report on a private company covering its busines | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgId` | number | Yes | CB Insights organization ID of an active company. Resolve a name or website to one with Look Up Organizations, before requesting company data; confirm current credit terms with CB Insights. | #### Output @@ -456,8 +426,6 @@ Retrieve funding rounds and cap table history for up to 100 organizations at onc | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgIds` | json | Yes | CB Insights organization IDs, 1-100 per request, e.g. \[129410, 1034157\] | | `limit` | number | No | Rows to return in a single response, 1-100 | | `nextPageToken` | string | No | Continuation token from a previous response; omit for the first page | @@ -483,8 +451,6 @@ Retrieve the rounds up to 100 organizations participated in as investors, with A | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgIds` | json | Yes | CB Insights organization IDs, 1-100 per request, e.g. \[129410, 1034157\] | | `limit` | number | No | Rows to return in a single response, 1-100 | | `nextPageToken` | string | No | Continuation token from a previous response; omit for the first page | @@ -510,8 +476,6 @@ Retrieve exit rounds for companies up to 100 organizations invested in before th | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgIds` | json | Yes | CB Insights organization IDs, 1-100 per request, e.g. \[129410, 1034157\] | | `limit` | number | No | Rows to return in a single response, 1-100 | | `nextPageToken` | string | No | Continuation token from a previous response; omit for the first page | @@ -537,8 +501,6 @@ Retrieve partnerships, client/vendor relationships, and licensing activity for u | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgIds` | json | Yes | CB Insights organization IDs, 1-100 per request, e.g. \[129410, 1034157\] | | `nextPageToken` | string | No | Continuation token from a previous response; omit for the first page | @@ -561,8 +523,6 @@ Retrieve leadership teams, board members, and the Management factor of the Mosai | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgIds` | json | Yes | CB Insights organization IDs, 1-100 per request, e.g. \[129410, 1034157\] | | `titleIds` | json | No | CB Insights person title IDs to filter the people returned, e.g. \[50, 75\] | @@ -584,8 +544,6 @@ Retrieve Mosaic Score, Commercial Maturity, and Exit Probability for up to 100 o | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgIds` | json | Yes | CB Insights organization IDs, 1-100 per request, e.g. \[129410, 1034157\] | #### Output @@ -606,8 +564,6 @@ Retrieve the estimated next-round funding window for up to 100 organizations at | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgIds` | json | Yes | CB Insights organization IDs, 1-100 per request, e.g. \[129410, 1034157\] | #### Output @@ -628,8 +584,6 @@ Retrieve reported and estimated revenue by calendar year for up to 100 organizat | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `orgIds` | json | Yes | CB Insights organization IDs, 1-100 per request, e.g. \[129410, 1034157\] | #### Output @@ -650,8 +604,6 @@ Ask ChatCBI a question in natural language and get an answer grounded in CB Insi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `message` | string | Yes | The question to ask, e.g. "Which emerging technology markets are seeing the highest equity funding growth right now?" | | `chatId` | string | No | Conversation ID returned by a previous call. Pass it to continue that conversation rather than starting a new one. | @@ -678,8 +630,6 @@ Retrieve the raw structured CB Insights data relevant to a question, for feeding | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CB Insights API client ID, exchanged for a bearer token before each call | -| `clientSecret` | string | Yes | CB Insights API client secret, exchanged for a bearer token before each call | | `message` | string | Yes | The question to retrieve context for. Must be under 10,000 characters. | #### Output diff --git a/apps/docs/content/docs/integrations/cloudformation.mdx b/apps/docs/content/docs/integrations/cloudformation.mdx index 87814805465..68d0f959991 100644 --- a/apps/docs/content/docs/integrations/cloudformation.mdx +++ b/apps/docs/content/docs/integrations/cloudformation.mdx @@ -36,7 +36,7 @@ List and describe CloudFormation stacks | Parameter | Type | Description | | --------- | ---- | ----------- | -| `stacks` | array | List of CloudFormation stacks with status, outputs, and tags | +| `stacks` | array | List of CloudFormation stacks \(stackName, stackId, stackStatus, stackStatusReason, detailedStatus, creationTime, lastUpdatedTime, deletionTime, description, enableTerminationProtection, driftInformation, outputs \[\{outputKey, outputValue, description, exportName\}\], tags, parameters \[\{parameterKey, parameterValue, usePreviousValue, resolvedValue\}\], capabilities, changeSetId, roleArn, parentId, rootId, notificationArns, timeoutInMinutes, disableRollback, deletionMode, retainExceptOnCreate\) | @@ -58,6 +58,8 @@ Create a new CloudFormation stack from a template | `tags` | json | No | Tags to apply to the stack and its resources \(e.g., \[\{"key": "env", "value": "prod"\}\]\) | | `onFailure` | string | No | Action to take on creation failure: ROLLBACK \(default\), DELETE, or DO_NOTHING | | `timeoutInMinutes` | number | No | Amount of time before the stack creation times out and rolls back | +| `roleArn` | string | No | ARN of an IAM service role CloudFormation assumes for stack operations \(defaults to the caller credentials or the role previously used for the stack\) | +| `enableTerminationProtection` | boolean | No | Whether to enable termination protection on the new stack \(default false\) | #### Output @@ -84,6 +86,7 @@ Update an existing CloudFormation stack with a new or previous template | `parameters` | json | No | Template input parameters \(e.g., \[\{"parameterKey": "InstanceType", "parameterValue": "t3.micro"\}\]\) | | `capabilities` | string | No | Comma-separated capabilities to acknowledge \(CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_AUTO_EXPAND\) | | `tags` | json | No | Tags to apply to the stack and its resources \(e.g., \[\{"key": "env", "value": "prod"\}\]\) | +| `roleArn` | string | No | ARN of an IAM service role CloudFormation assumes for stack operations \(defaults to the caller credentials or the role previously used for the stack\) | #### Output @@ -155,6 +158,8 @@ Preview the changes a stack create or update would make before applying them | `capabilities` | string | No | Comma-separated capabilities to acknowledge \(CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_AUTO_EXPAND\) | | `changeSetType` | string | No | CREATE \(default, new stack\), UPDATE \(existing stack\), or IMPORT \(import existing resources\) | | `description` | string | No | Description of the change set for reference | +| `tags` | json | No | Tags to apply to the stack and its resources when the change set executes \(e.g., \[\{"key": "env", "value": "prod"\}\]\) | +| `roleArn` | string | No | ARN of an IAM service role CloudFormation assumes for stack operations \(defaults to the caller credentials or the role previously used for the stack\) | #### Output @@ -178,6 +183,7 @@ View the resource changes a change set would make and its execution status | `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | | `changeSetName` | string | Yes | Name or ARN of the change set to describe | | `stackName` | string | No | Name or ID of the stack the change set belongs to \(required if changeSetName is not an ARN\) | +| `nextToken` | string | No | Pagination token from a previous call to fetch the next page of changes | #### Output @@ -193,7 +199,16 @@ View the resource changes a change set would make and its execution status | `statusReason` | string | Reason for the current status, particularly if failed | | `creationTime` | number | Timestamp the change set was created | | `capabilities` | array | Capabilities required to execute the change set | -| `changes` | array | List of resource changes \(action, logical/physical resource ID, resource type, replacement\) | +| `changes` | array | List of resource changes \(action, logicalResourceId, physicalResourceId, resourceType, replacement, type, policyAction, scope, changeSetId, details \[\{attribute, name, requiresRecreation, evaluation, changeSource, causingEntity\}\]\) | +| `parameters` | array | Stack parameters for the change set \(parameterKey, parameterValue, usePreviousValue, resolvedValue\) | +| `tags` | array | Tags associated with the change set \(key, value\) | +| `notificationArns` | array | SNS topic ARNs that receive stack event notifications | +| `includeNestedStacks` | boolean | Whether change sets were created for nested stacks | +| `parentChangeSetId` | string | ID of the parent change set, for nested change sets | +| `rootChangeSetId` | string | ID of the top-level change set, for nested change sets | +| `onStackFailure` | string | Behavior when stack operations fail \(DO_NOTHING, ROLLBACK, DELETE\) | +| `importExistingResources` | boolean | Whether the change set imports resources that already exist | +| `nextToken` | string | Pagination token for the next page of changes, if more remain | @@ -306,7 +321,7 @@ Get the event history for a CloudFormation stack | Parameter | Type | Description | | --------- | ---- | ----------- | -| `events` | array | List of stack events with resource status and timestamps | +| `events` | array | List of stack events \(stackId, eventId, stackName, operationId, logicalResourceId, physicalResourceId, resourceType, resourceStatus, resourceStatusReason, detailedStatus, resourceProperties, clientRequestToken, hookType, hookStatus, hookStatusReason, hookInvocationPoint, hookFailureMode, timestamp\) | @@ -352,7 +367,7 @@ Get a summary of a template or deployed stack: resource types, required capabili | Parameter | Type | Description | | --------- | ---- | ----------- | | `description` | string | Template description | -| `parameters` | array | Template parameters with types, defaults, and descriptions | +| `parameters` | array | Template parameters \(parameterKey, defaultValue, parameterType, noEcho, description, allowedValues\) | | `capabilities` | array | Required capabilities \(e.g., CAPABILITY_IAM\) | | `capabilitiesReason` | string | Reason capabilities are required | | `resourceTypes` | array | AWS resource types declared in the template \(e.g., AWS::S3::Bucket\) | diff --git a/apps/docs/content/docs/integrations/cloudwatch.mdx b/apps/docs/content/docs/integrations/cloudwatch.mdx index 96f9907f869..0ecf5169bf5 100644 --- a/apps/docs/content/docs/integrations/cloudwatch.mdx +++ b/apps/docs/content/docs/integrations/cloudwatch.mdx @@ -48,6 +48,7 @@ Run a CloudWatch Log Insights query against one or more log groups | ↳ `recordsMatched` | number | Number of log records that matched the query | | ↳ `recordsScanned` | number | Total log records scanned | | `status` | string | Query completion status \(Complete, Failed, Cancelled, or Timeout\) | +| `queryId` | string | ID of the Logs Insights query | @@ -69,6 +70,7 @@ Search log events across all streams in a log group by filter pattern and time r | `endTime` | number | No | End time as Unix epoch seconds | | `startFromHead` | boolean | No | Return the earliest matching events first instead of the latest | | `limit` | number | No | Maximum number of events to return | +| `nextToken` | string | No | Pagination token from a previous call to continue where it stopped | #### Output @@ -79,6 +81,8 @@ Search log events across all streams in a log group by filter pattern and time r | ↳ `timestamp` | number | Event timestamp in epoch milliseconds | | ↳ `message` | string | Log event message | | ↳ `ingestionTime` | number | Ingestion time in epoch milliseconds | +| ↳ `eventId` | string | Unique ID of the log event | +| `nextToken` | string | Token to fetch the next page of matching events, if more remain | @@ -125,6 +129,7 @@ Retrieve log events from a specific CloudWatch log stream | `startTime` | number | No | Start time as Unix epoch seconds | | `endTime` | number | No | End time as Unix epoch seconds | | `limit` | number | No | Maximum number of events to return | +| `nextToken` | string | No | Token from a previous call \(nextForwardToken or nextBackwardToken\) to page through the stream | #### Output @@ -134,6 +139,8 @@ Retrieve log events from a specific CloudWatch log stream | ↳ `timestamp` | number | Event timestamp in epoch milliseconds | | ↳ `message` | string | Log event message | | ↳ `ingestionTime` | number | Ingestion time in epoch milliseconds | +| `nextForwardToken` | string | Token for the next, newer page of events \(returns the same token when the end of the stream is reached\) | +| `nextBackwardToken` | string | Token for the previous, older page of events | @@ -232,8 +239,10 @@ Get statistics for a CloudWatch metric over a time range | `startTime` | number | Yes | Start time as Unix epoch seconds | | `endTime` | number | Yes | End time as Unix epoch seconds | | `period` | number | Yes | Granularity in seconds \(e.g., 60, 300, 3600\) | -| `statistics` | array | Yes | Statistics to retrieve \(Average, Sum, Minimum, Maximum, SampleCount\) | +| `statistics` | array | No | Statistics to retrieve \(Average, Sum, Minimum, Maximum, SampleCount\). Required unless extendedStatistics is set, which takes precedence | | `dimensions` | string | No | Dimensions as JSON \(e.g., \{"InstanceId": "i-1234"\}\) | +| `extendedStatistics` | array | No | Percentile statistics to retrieve \(e.g., \["p99", "p95"\]\). AWS accepts either Statistics or ExtendedStatistics, so when set these are requested instead of statistics | +| `unit` | string | No | Only return data reported with this unit \(e.g., Percent, Milliseconds\). Omit to return all units | #### Output @@ -248,6 +257,7 @@ Get statistics for a CloudWatch metric over a time range | ↳ `maximum` | number | Maximum statistic value | | ↳ `sampleCount` | number | Sample count statistic value | | ↳ `unit` | string | Unit of the metric | +| ↳ `extendedStatistics` | json | Percentile values keyed by statistic \(e.g., \{"p99": 120.5\}\) | @@ -296,6 +306,7 @@ List and filter CloudWatch alarms | `stateValue` | string | No | Filter by alarm state \(OK, ALARM, INSUFFICIENT_DATA\) | | `alarmType` | string | No | Filter by alarm type \(MetricAlarm, CompositeAlarm\) | | `limit` | number | No | Maximum number of alarms to return | +| `nextToken` | string | No | Pagination token from a previous call | #### Output @@ -309,7 +320,29 @@ List and filter CloudWatch alarms | ↳ `metricName` | string | Metric name \(MetricAlarm only\) | | ↳ `namespace` | string | Metric namespace \(MetricAlarm only\) | | ↳ `threshold` | number | Threshold value \(MetricAlarm only\) | +| ↳ `comparisonOperator` | string | Comparison operator \(MetricAlarm only\) | +| ↳ `evaluationPeriods` | number | Number of periods evaluated \(MetricAlarm only\) | | ↳ `stateUpdatedTimestamp` | number | Epoch ms when state last changed | +| ↳ `alarmType` | string | MetricAlarm or CompositeAlarm | +| ↳ `alarmDescription` | string | Alarm description | +| ↳ `actionsEnabled` | boolean | Whether actions run on state changes | +| ↳ `alarmActions` | array | ARNs of actions run on ALARM | +| ↳ `okActions` | array | ARNs of actions run on OK | +| ↳ `insufficientDataActions` | array | ARNs of actions run on INSUFFICIENT_DATA | +| ↳ `stateReasonData` | string | Machine-readable JSON explaining the state | +| ↳ `stateTransitionedTimestamp` | number | Epoch ms when the state value last changed | +| ↳ `alarmConfigurationUpdatedTimestamp` | number | Epoch ms when the alarm configuration last changed | +| ↳ `statistic` | string | Statistic evaluated \(MetricAlarm only\) | +| ↳ `extendedStatistic` | string | Percentile statistic evaluated \(MetricAlarm only\) | +| ↳ `dimensions` | array | Metric dimensions as \{name, value\} \(MetricAlarm only\) | +| ↳ `period` | number | Evaluation period in seconds \(MetricAlarm only\) | +| ↳ `unit` | string | Metric unit \(MetricAlarm only\) | +| ↳ `datapointsToAlarm` | number | Breaching datapoints needed to alarm \(MetricAlarm only\) | +| ↳ `treatMissingData` | string | How missing data is treated \(MetricAlarm only\) | +| ↳ `thresholdMetricId` | string | ID of the metric-math threshold query \(anomaly detection alarms\) | +| ↳ `metrics` | array | Metric-math queries \[\{id, expression, label, returnData, period, accountId, metricStat\}\] \(MetricAlarm only\) | +| ↳ `alarmRule` | string | Rule expression \(CompositeAlarm only\) | +| `nextToken` | string | Pagination token for the next page of alarms | @@ -341,6 +374,8 @@ Retrieve state-change and configuration history for CloudWatch alarms | ↳ `timestamp` | number | Epoch ms when the history item occurred | | ↳ `historyItemType` | string | ConfigurationUpdate, StateUpdate, Action, or contributor variants | | ↳ `historySummary` | string | Human-readable summary of the event | +| ↳ `historyData` | string | JSON string with event details \(e.g., old and new state with reason data for StateUpdate\) | +| ↳ `alarmContributorId` | string | ID of the alarm contributor, for contributor history items | diff --git a/apps/docs/content/docs/integrations/confluence.mdx b/apps/docs/content/docs/integrations/confluence.mdx index 0eb3aee5812..1b14222bcb2 100644 --- a/apps/docs/content/docs/integrations/confluence.mdx +++ b/apps/docs/content/docs/integrations/confluence.mdx @@ -86,6 +86,10 @@ Retrieve content from Confluence pages using the Confluence API. | Parameter | Type | Description | | --------- | ---- | ----------- | | `ts` | string | ISO 8601 timestamp of the operation | +| `labels` | array | Labels on the content \(first 50\) | +| ↳ `id` | string | Unique label identifier | +| ↳ `name` | string | Label name | +| ↳ `prefix` | string | Label prefix/type \(e.g., global, my, team\) | | `pageId` | string | Confluence page ID | | `title` | string | Page title | | `content` | string | Page content with HTML tags stripped | @@ -93,6 +97,9 @@ Retrieve content from Confluence pages using the Confluence API. | `spaceId` | string | ID of the space containing the page | | `parentId` | string | ID of the parent page | | `authorId` | string | Account ID of the page author | +| `ownerId` | string | Account ID of the current page owner | +| `lastOwnerId` | string | Account ID of the previous page owner | +| `parentType` | string | Type of the parent content \(e.g., page, whiteboard, database, embed, folder\) | | `createdAt` | string | ISO 8601 timestamp when the page was created | | `url` | string | URL to view the page in Confluence | | `body` | object | Raw page body content in storage format | @@ -236,7 +243,7 @@ List all pages within a specific Confluence space. Supports pagination and filte | `spaceId` | string | Yes | The ID of the Confluence space to list pages from | | `limit` | number | No | Maximum number of pages to return \(default: 50, max: 250\) | | `status` | string | No | Filter pages by status: current, archived, trashed, or draft | -| `bodyFormat` | string | No | Format for page body content: storage, atlas_doc_format, or view. If not specified, body is not included. | +| `bodyFormat` | string | No | Format for page body content: storage or atlas_doc_format. If not specified, body is not included. | | `cursor` | string | No | Pagination cursor from previous response to get the next page of results | #### Output @@ -251,6 +258,7 @@ List all pages within a specific Confluence space. Supports pagination and filte | ↳ `spaceId` | string | ID of the space containing the page | | ↳ `parentId` | string | ID of the parent page \(null if top-level\) | | ↳ `authorId` | string | Account ID of the page author | +| ↳ `ownerId` | string | Account ID of the current page owner | | ↳ `createdAt` | string | ISO 8601 timestamp when the page was created | | ↳ `version` | object | Page version information | | ↳ `number` | number | Version number | @@ -509,6 +517,8 @@ Search for content across Confluence pages, blog posts, and other content. | `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) | | `query` | string | Yes | Search query string | | `limit` | number | No | Maximum number of results to return \(default: 25\) | +| `cql` | string | No | Extra CQL clauses ANDed with the text search, e.g. type = page AND space = "ENG" AND lastModified > now\("-7d"\) | +| `cursor` | string | No | Pagination cursor from a previous response \(nextCursor\) | #### Output @@ -529,6 +539,8 @@ Search for content across Confluence pages, blog posts, and other content. | ↳ `name` | string | Space name | | ↳ `lastModified` | string | ISO 8601 timestamp of last modification | | ↳ `entityType` | string | Entity type identifier \(e.g., content, space\) | +| `totalSize` | number | Total number of matching results | +| `nextCursor` | string | Cursor for fetching the next page of results | @@ -547,6 +559,7 @@ Search for content within a specific Confluence space. Optionally filter by text | `query` | string | No | Text search query. If not provided, returns all content in the space. | | `contentType` | string | No | Filter by content type: page, blogpost, attachment, or comment | | `limit` | number | No | Maximum number of results to return \(default: 25, max: 250\) | +| `cursor` | string | No | Pagination cursor from a previous response \(nextCursor\) | #### Output @@ -569,6 +582,7 @@ Search for content within a specific Confluence space. Optionally filter by text | ↳ `name` | string | Space name | | ↳ `lastModified` | string | ISO 8601 timestamp of last modification | | ↳ `entityType` | string | Entity type identifier \(e.g., content, space\) | +| `nextCursor` | string | Cursor for fetching the next page of results | @@ -623,13 +637,17 @@ Get a specific Confluence blog post by ID, including its content. | --------- | ---- | -------- | ----------- | | `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) | | `blogPostId` | string | Yes | The ID of the blog post to retrieve | -| `bodyFormat` | string | No | Format for blog post body: storage, atlas_doc_format, or view | +| `bodyFormat` | string | No | Format for blog post body: storage, atlas_doc_format, view, or export_view \(default: storage\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `ts` | string | ISO 8601 timestamp of the operation | +| `labels` | array | Labels on the content \(first 50\) | +| ↳ `id` | string | Unique label identifier | +| ↳ `name` | string | Label name | +| ↳ `prefix` | string | Label prefix/type \(e.g., global, my, team\) | | `id` | string | Blog post ID | | `title` | string | Blog post title | | `status` | string | Blog post status | @@ -716,7 +734,7 @@ List all blog posts within a specific Confluence space. | `spaceId` | string | Yes | The ID of the Confluence space to list blog posts from | | `limit` | number | No | Maximum number of blog posts to return \(default: 25, max: 250\) | | `status` | string | No | Filter by status: current, archived, trashed, or draft | -| `bodyFormat` | string | No | Format for blog post body: storage, atlas_doc_format, or view | +| `bodyFormat` | string | No | Format for blog post body: storage or atlas_doc_format. If not specified, body is not included. | | `cursor` | string | No | Pagination cursor from previous response | #### Output @@ -765,6 +783,7 @@ Add a comment to a Confluence page. | `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) | | `pageId` | string | Yes | Confluence page ID to comment on | | `comment` | string | Yes | Comment text in Confluence storage format | +| `parentCommentId` | string | No | ID of a footer or inline comment to reply to. When set, the comment is posted as a reply in that thread. | #### Output @@ -773,6 +792,15 @@ Add a comment to a Confluence page. | `ts` | string | Timestamp of creation | | `commentId` | string | Created comment ID | | `pageId` | string | Page ID | +| `parentCommentId` | string | ID of the parent comment when the comment is a reply | +| `status` | string | Comment status \(e.g., current\) | +| `version` | object | Comment version information | +| ↳ `number` | number | Version number | +| ↳ `message` | string | Version message | +| ↳ `minorEdit` | boolean | Whether this is a minor edit | +| ↳ `authorId` | string | Account ID of the version author | +| ↳ `createdAt` | string | ISO 8601 timestamp of version creation | +| `webUrl` | string | URL to view the comment in Confluence | @@ -789,8 +817,9 @@ List all comments on a Confluence page. | `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) | | `pageId` | string | Yes | Confluence page ID to list comments from | | `limit` | number | No | Maximum number of comments to return \(default: 25\) | -| `bodyFormat` | string | No | Format for the comment body: storage, atlas_doc_format, view, or export_view \(default: storage\) | +| `bodyFormat` | string | No | Format for the comment body: storage or atlas_doc_format \(default: storage\) | | `cursor` | string | No | Pagination cursor from previous response | +| `commentType` | string | No | Which comments to list: footer \(comments at the bottom of the page\) or inline \(comments attached to highlighted text\). Default: footer | #### Output @@ -815,6 +844,11 @@ List all comments on a Confluence page. | ↳ `minorEdit` | boolean | Whether this is a minor edit | | ↳ `authorId` | string | Account ID of the version author | | ↳ `createdAt` | string | ISO 8601 timestamp of version creation | +| ↳ `commentType` | string | Comment type that was listed \(footer or inline\) | +| ↳ `resolutionStatus` | string | Inline comment resolution status \(open, reopened, resolved, dangling\) | +| ↳ `inlineOriginalSelection` | string | Page text the inline comment is attached to | +| ↳ `inlineMarkerRef` | string | Marker reference of the highlighted text for an inline comment | +| ↳ `webUrl` | string | URL to view the comment in Confluence | | `nextCursor` | string | Cursor for fetching the next page of results | @@ -927,6 +961,8 @@ List all attachments on a Confluence page. | ↳ `pageId` | string | ID of the page the attachment belongs to | | ↳ `blogPostId` | string | ID of the blog post the attachment belongs to | | ↳ `comment` | string | Comment/description of the attachment | +| ↳ `createdAt` | string | ISO 8601 timestamp when the attachment was created | +| ↳ `fileId` | string | Media file ID of the attachment | | ↳ `version` | object | Attachment version information | | ↳ `number` | number | Version number | | ↳ `message` | string | Version message | @@ -1067,6 +1103,7 @@ Retrieve all pages that have a specific label applied. | ↳ `spaceId` | string | ID of the space containing the page | | ↳ `parentId` | string | ID of the parent page \(null if top-level\) | | ↳ `authorId` | string | Account ID of the page author | +| ↳ `ownerId` | string | Account ID of the current page owner | | ↳ `createdAt` | string | ISO 8601 timestamp when the page was created | | ↳ `version` | object | Page version information | | ↳ `number` | number | Version number | @@ -1137,6 +1174,7 @@ Get details about a specific Confluence space. | `description` | object | Space description content | | ↳ `value` | string | Description text content | | ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) | +| `spaceOwnerId` | string | Account ID of the space owner | @@ -1258,9 +1296,11 @@ List all Confluence spaces accessible to the user. | ↳ `authorId` | string | Account ID of the space creator | | ↳ `createdAt` | string | ISO 8601 timestamp when the space was created | | ↳ `homepageId` | string | ID of the space homepage | -| ↳ `description` | object | Space description | +| ↳ `description` | object | Space description \(plain text\) | | ↳ `value` | string | Description text content | | ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) | +| ↳ `spaceOwnerId` | string | Account ID of the space owner | +| ↳ `webUrl` | string | URL to view the space in Confluence | | `nextCursor` | string | Cursor for fetching the next page of results | diff --git a/apps/docs/content/docs/integrations/databricks-credentials.mdx b/apps/docs/content/docs/integrations/databricks-credentials.mdx index 0f332e9b62d..e660c98dd94 100644 --- a/apps/docs/content/docs/integrations/databricks-credentials.mdx +++ b/apps/docs/content/docs/integrations/databricks-credentials.mdx @@ -29,18 +29,28 @@ Every option resolves on Sim's servers at run time; tokens never appear in block In Sim, add a **Databricks connection**, choose **Service principal (keyless, via Sim OIDC)**, and enter the workspace host and the service principal's **application ID**. - A Databricks account admin runs the **Federation policy** command from the dialog with the service principal's numeric ID. First sign in to the account with `databricks auth login --host --account-id ` (not a workspace profile or the workspace web terminal). Find the numeric ID with `databricks account service-principals list --filter 'applicationId eq ""'`, or on the service principal's page in the account console. The policy trusts Sim's issuer, the Sim audience, and this connection's subject only. + A Databricks account admin runs the **Federation policy** command from the dialog with the service principal's numeric ID. First sign in to the account with `databricks auth login --host --account-id ` (not a workspace profile or the workspace web terminal). Find the numeric ID with `databricks account service-principals list --filter 'applicationId eq ""'`, or on the service principal's page in the account console. The policy trusts Sim's issuer, the audience, and this connection's subject only. Make sure the service principal can access the workspace and the resources your workflows use, then save. Sim verifies the connection by exchanging a token and reading the service principal's identity, and refuses a policy that would also trust a different subject, since every Sim workspace shares the issuer and audience. -Reconnecting a keyless connection keeps its subject by default, so the federation policy keeps working. +Reconnecting a keyless connection keeps its subject and audience by default, so the federation policy keeps working. + +### Use your own audience [#keyless-audience] + +By default Sim's tokens carry the audience `sim-databricks`, which every Sim connection shares. To pin the federation policy to a value only your tokens carry, such as your Databricks account ID, enter it in the dialog's **Audience** field before you copy the command. Sim then issues this connection's tokens for that audience, and the command lists it in the policy's `audiences`: + +```bash +databricks account service-principal-federation-policy create --json '{"oidc_policy":{"issuer":"","audiences":[""],"subject":""}}' +``` + +Databricks accepts any non-empty audience; Sim takes up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`, and refuses another cloud's default audience. To change the audience of an existing connection, add the new value to the policy's `audiences` (with `databricks account service-principal-federation-policy update`), then reconnect with it. Clearing the field goes back to the default. ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject databricks-service-account`, trust the printed subject as above, then add `host` and `clientId` to its `keylessConfig` and pass it to `sim credentials create databricks-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. +Run `sim credentials keyless-subject databricks-service-account`, trust the printed subject as above, then add `host` and `clientId` (and optionally `audience`) to its `keylessConfig` and pass it to `sim credentials create databricks-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. ## Self-hosted deployments @@ -48,5 +58,5 @@ Keyless connections need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issu ## Troubleshooting -- **"Databricks rejected the token request":** the federation policy's issuer, audience, or subject doesn't match the dialog, or the policy is on a different service principal than the application ID you entered. +- **"Databricks rejected the token request":** the federation policy's issuer, audience, or subject doesn't match the dialog (the error names the audience Sim sent), or the policy is on a different service principal than the application ID you entered. - **"This service principal's federation policy also trusts other Sim credentials":** the policy's `subject_claim` isn't `sub`, so it matches more than this connection. Recreate it with the command from the dialog. diff --git a/apps/docs/content/docs/integrations/dynamodb.mdx b/apps/docs/content/docs/integrations/dynamodb.mdx index 0aaae67b69a..5dd359e6d18 100644 --- a/apps/docs/content/docs/integrations/dynamodb.mdx +++ b/apps/docs/content/docs/integrations/dynamodb.mdx @@ -33,6 +33,8 @@ Get an item from a DynamoDB table by primary key | `tableName` | string | Yes | DynamoDB table name \(e.g., "Users", "Orders"\) | | `key` | json | Yes | Primary key of the item to retrieve \(e.g., \{"pk": "USER#123"\} or \{"pk": "ORDER#456", "sk": "ITEM#789"\}\) | | `consistentRead` | boolean | No | Use strongly consistent read | +| `projectionExpression` | string | No | Attributes to retrieve \(e.g., "pk, #name, email"\). Returns all attributes if omitted | +| `expressionAttributeNames` | json | No | Attribute name mappings for reserved words used in projectionExpression \(e.g., \{"#name": "name"\}\) | #### Output @@ -59,6 +61,7 @@ Put an item into a DynamoDB table | `conditionExpression` | string | No | Condition that must be met for the put to succeed \(e.g., "attribute_not_exists\(pk\)" to prevent overwrites\) | | `expressionAttributeNames` | json | No | Attribute name mappings for reserved words used in conditionExpression \(e.g., \{"#name": "name"\}\) | | `expressionAttributeValues` | json | No | Expression attribute values used in conditionExpression \(e.g., \{":expected": "value"\}\) | +| `returnValues` | string | No | Set to "ALL_OLD" to return the item this put replaced as attributes \(default: "NONE"\) | #### Output @@ -66,6 +69,7 @@ Put an item into a DynamoDB table | --------- | ---- | ----------- | | `message` | string | Operation status message | | `item` | json | Created item | +| `attributes` | json | Item that was replaced, when returnValues is ALL_OLD \(null if no item existed\) | @@ -89,6 +93,8 @@ Query items from a DynamoDB table using key conditions | `limit` | number | No | Maximum number of items to return \(e.g., 10, 50, 100\) | | `exclusiveStartKey` | json | No | Pagination token from a previous query's lastEvaluatedKey to continue fetching results | | `scanIndexForward` | boolean | No | Sort order for the sort key: true for ascending \(default\), false for descending | +| `projectionExpression` | string | No | Attributes to retrieve \(e.g., "pk, sk, #name, email"\) | +| `consistentRead` | boolean | No | Use a strongly consistent read \(default: false\). Not supported on global secondary indexes | #### Output @@ -97,6 +103,7 @@ Query items from a DynamoDB table using key conditions | `message` | string | Operation status message | | `items` | array | Array of items returned | | `count` | number | Number of items returned | +| `scannedCount` | number | Number of items evaluated before the filter expression was applied | | `lastEvaluatedKey` | json | Pagination token to pass as exclusiveStartKey to fetch the next page of results | @@ -119,6 +126,10 @@ Scan all items in a DynamoDB table | `expressionAttributeValues` | json | No | Expression attribute values \(e.g., \{":minAge": 18, ":status": "active"\}\) | | `limit` | number | No | Maximum number of items to return \(e.g., 10, 50, 100\) | | `exclusiveStartKey` | json | No | Pagination token from a previous scan's lastEvaluatedKey to continue fetching results | +| `indexName` | string | No | Secondary index to scan instead of the table \(e.g., "GSI1"\) | +| `consistentRead` | boolean | No | Use a strongly consistent read \(default: false\). Not supported on global secondary indexes | +| `segment` | number | No | Segment to scan in a parallel scan \(0 to totalSegments - 1\). Requires totalSegments | +| `totalSegments` | number | No | Total number of segments in a parallel scan \(1-1000000\). Requires segment | #### Output @@ -127,6 +138,7 @@ Scan all items in a DynamoDB table | `message` | string | Operation status message | | `items` | array | Array of items returned | | `count` | number | Number of items returned | +| `scannedCount` | number | Number of items evaluated before the filter expression was applied | | `lastEvaluatedKey` | json | Pagination token to pass as exclusiveStartKey to fetch the next page of results | @@ -148,13 +160,14 @@ Update an item in a DynamoDB table | `expressionAttributeNames` | json | No | Attribute name mappings for reserved words \(e.g., \{"#name": "name", "#count": "count"\}\) | | `expressionAttributeValues` | json | No | Expression attribute values \(e.g., \{":name": "John", ":age": 30, ":inc": 1\}\) | | `conditionExpression` | string | No | Condition that must be met for the update to succeed \(e.g., "attribute_exists\(pk\)" or "version = :expectedVersion"\) | +| `returnValues` | string | No | Which attributes to return as item: "ALL_NEW" \(default\), "UPDATED_NEW", "ALL_OLD", "UPDATED_OLD", or "NONE" | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `message` | string | Operation status message | -| `item` | json | Updated item with all attributes | +| `item` | json | Item attributes selected by returnValues \(all attributes after the update by default\) | @@ -174,12 +187,14 @@ Delete an item from a DynamoDB table | `conditionExpression` | string | No | Condition that must be met for the delete to succeed \(e.g., "attribute_exists\(pk\)"\) | | `expressionAttributeNames` | json | No | Attribute name mappings for reserved words used in conditionExpression \(e.g., \{"#status": "status"\}\) | | `expressionAttributeValues` | json | No | Expression attribute values used in conditionExpression \(e.g., \{":status": "active"\}\) | +| `returnValues` | string | No | Set to "ALL_OLD" to return the deleted item as attributes \(default: "NONE"\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `message` | string | Operation status message | +| `attributes` | json | Deleted item, when returnValues is ALL_OLD \(null if no item existed\) | @@ -202,7 +217,7 @@ Introspect DynamoDB to list tables or get detailed schema information for a spec | --------- | ---- | ----------- | | `message` | string | Operation status message | | `tables` | array | List of table names in the region | -| `tableDetails` | json | Detailed schema information for a specific table | +| `tableDetails` | json | Detailed schema for a specific table \(tableName, tableStatus, keySchema, attributeDefinitions, globalSecondaryIndexes, localSecondaryIndexes, itemCount, tableSizeBytes, billingMode, tableArn, creationDateTime, deletionProtectionEnabled, streamEnabled, streamViewType, latestStreamArn\) | diff --git a/apps/docs/content/docs/integrations/ec2.mdx b/apps/docs/content/docs/integrations/ec2.mdx index 1065a8b4368..108abc67de4 100644 --- a/apps/docs/content/docs/integrations/ec2.mdx +++ b/apps/docs/content/docs/integrations/ec2.mdx @@ -63,7 +63,12 @@ List EC2 instances with their state, type, networking, and tags | ↳ `instanceId` | string | Instance ID | | ↳ `instanceType` | string | Instance type \(e.g., t3.micro\) | | ↳ `state` | string | Instance state \(pending, running, stopping, stopped, shutting-down, terminated\) | +| ↳ `stateCode` | number | Numeric state code \(0 pending, 16 running, 32 shutting-down, 48 terminated, 64 stopping, 80 stopped\) | | ↳ `stateReason` | string | Reason for the most recent state transition | +| ↳ `stateReasonCode` | string | Code for the most recent state change \(e.g., Client.UserInitiatedShutdown\) | +| ↳ `stateTransitionReason` | string | Reason for the most recent state transition, including when it happened | +| ↳ `reservationId` | string | Reservation ID the instance was launched in | +| ↳ `ownerId` | string | AWS account ID that owns the reservation | | ↳ `launchTime` | string | Launch time \(ISO 8601\) | | ↳ `availabilityZone` | string | Availability Zone | | ↳ `vpcId` | string | VPC ID | @@ -75,11 +80,36 @@ List EC2 instances with their state, type, networking, and tags | ↳ `imageId` | string | AMI ID used to launch the instance | | ↳ `keyName` | string | Key pair name | | ↳ `platformDetails` | string | Platform details \(e.g., Linux/UNIX, Windows\) | +| ↳ `platform` | string | Set to Windows for Windows instances; null for Linux/UNIX | | ↳ `architecture` | string | CPU architecture | | ↳ `iamInstanceProfileArn` | string | ARN of the attached IAM instance profile | +| ↳ `ipv6Address` | string | Primary IPv6 address | +| ↳ `tenancy` | string | Placement tenancy \(default, dedicated, host\) | +| ↳ `instanceLifecycle` | string | Lifecycle type \(e.g., spot, scheduled, capacity-block\); null for On-Demand | +| ↳ `spotInstanceRequestId` | string | Spot Instance request ID | +| ↳ `monitoringState` | string | Detailed monitoring state \(disabled, disabling, enabled, pending\) | +| ↳ `ebsOptimized` | boolean | Whether the instance is EBS-optimized | +| ↳ `rootDeviceName` | string | Root device name \(e.g., /dev/xvda\) | +| ↳ `rootDeviceType` | string | Root device type \(ebs or instance-store\) | +| ↳ `metadataHttpTokens` | string | IMDS token requirement; required means IMDSv2 only, optional allows IMDSv1 | | ↳ `securityGroups` | array | Attached security groups | | ↳ `groupId` | string | Security group ID | | ↳ `groupName` | string | Security group name | +| ↳ `blockDeviceMappings` | array | Attached EBS volumes | +| ↳ `deviceName` | string | Device name \(e.g., /dev/sdh\) | +| ↳ `volumeId` | string | EBS volume ID | +| ↳ `status` | string | Attachment state \(attaching, attached, detaching, detached\) | +| ↳ `attachTime` | string | Attach time \(ISO 8601\) | +| ↳ `deleteOnTermination` | boolean | Whether the volume is deleted when the instance terminates | +| ↳ `networkInterfaces` | array | Attached network interfaces | +| ↳ `networkInterfaceId` | string | Network interface ID | +| ↳ `subnetId` | string | Subnet ID | +| ↳ `vpcId` | string | VPC ID | +| ↳ `privateIpAddress` | string | Primary private IPv4 address | +| ↳ `publicIp` | string | Associated public IPv4 address | +| ↳ `macAddress` | string | MAC address | +| ↳ `status` | string | Interface status \(available, associated, attaching, in-use, detaching\) | +| ↳ `deviceIndex` | number | Device index on the instance \(0 is the primary interface\) | diff --git a/apps/docs/content/docs/integrations/gmail.mdx b/apps/docs/content/docs/integrations/gmail.mdx index f5928706260..3379d3ba308 100644 --- a/apps/docs/content/docs/integrations/gmail.mdx +++ b/apps/docs/content/docs/integrations/gmail.mdx @@ -51,9 +51,10 @@ Send emails using Gmail. | `body` | string | Yes | Email body content | | `contentType` | string | No | Content type for the email body \(text or html\) | | `threadId` | string | No | Thread ID to reply to \(for threading\) | -| `replyToMessageId` | string | No | Gmail message ID to reply to - use the "id" field from Gmail Read results \(not the RFC "messageId"\) | +| `replyToMessageId` | string | No | Gmail message ID to reply to - use the "id" field from Gmail Read results \(not "rfcMessageId"\) | | `cc` | string | No | CC recipients \(comma-separated\) | | `bcc` | string | No | BCC recipients \(comma-separated\) | +| `replyTo` | string | No | Reply-To address that replies should go to \(e.g., support@example.com\) | | `attachments` | file[] | No | Files to attach to the email | #### Output @@ -81,9 +82,10 @@ Draft emails using Gmail. | `body` | string | Yes | Email body content | | `contentType` | string | No | Content type for the email body \(text or html\) | | `threadId` | string | No | Thread ID to reply to \(for threading\) | -| `replyToMessageId` | string | No | Gmail message ID to reply to - use the "id" field from Gmail Read results \(not the RFC "messageId"\) | +| `replyToMessageId` | string | No | Gmail message ID to reply to - use the "id" field from Gmail Read results \(not "rfcMessageId"\) | | `cc` | string | No | CC recipients \(comma-separated\) | | `bcc` | string | No | BCC recipients \(comma-separated\) | +| `replyTo` | string | No | Reply-To address that replies should go to \(e.g., support@example.com\) | | `attachments` | file[] | No | Files to attach to the email draft | #### Output @@ -113,9 +115,10 @@ Update an existing Gmail draft in place without deleting and recreating it. | `body` | string | Yes | Email body content | | `contentType` | string | No | Content type for the email body \(text or html\) | | `threadId` | string | No | Thread ID to associate the draft with \(for threading\) | -| `replyToMessageId` | string | No | Gmail message ID to reply to - use the "id" field from Gmail Read results \(not the RFC "messageId"\) | +| `replyToMessageId` | string | No | Gmail message ID to reply to - use the "id" field from Gmail Read results \(not "rfcMessageId"\) | | `cc` | string | No | CC recipients \(comma-separated\) | | `bcc` | string | No | BCC recipients \(comma-separated\) | +| `replyTo` | string | No | Reply-To address that replies should go to \(e.g., support@example.com\) | | `attachments` | file[] | No | Files to attach to the email draft | #### Output @@ -154,13 +157,21 @@ Read emails from Gmail. | `labelIds` | array | Email labels | | `from` | string | Sender email address | | `to` | string | Recipient email address | +| `cc` | string | Cc recipients \(raw Cc header\) | +| `bcc` | string | Bcc recipients \(only present on messages you sent or drafted\) | +| `replyTo` | string | Reply-To header | | `subject` | string | Email subject | | `date` | string | Email date | +| `rfcMessageId` | string | RFC Message-ID header \(not the Gmail message id\) | +| `inReplyTo` | string | In-Reply-To header | +| `references` | string | References header | +| `snippet` | string | Short excerpt of the message text | +| `internalDate` | string | Gmail internal creation timestamp in epoch milliseconds | | `body` | string | Email body text \(best-effort plain text\) | | `hasAttachments` | boolean | Whether the email has attachments | | `attachmentCount` | number | Number of attachments | | `attachments` | file[] | Downloaded attachments \(if enabled\) | -| `results` | json | Summary results when reading multiple messages | +| `results` | json | Summary results when reading multiple messages \(id, threadId, subject, from, to, cc, bcc, replyTo, date, rfcMessageId, inReplyTo, references, snippet, internalDate\) | @@ -175,13 +186,16 @@ Search emails in Gmail. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `query` | string | Yes | Search query for emails | -| `maxResults` | number | No | Maximum number of results to return \(e.g., 10, 25, 50\) | +| `maxResults` | number | No | Maximum number of results to return \(e.g., 10, 25, 50; Gmail default: 100, max: 500\) | +| `pageToken` | string | No | Page token from a previous search \(nextPageToken\) to fetch the next page | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `results` | json | Array of search results | +| `results` | json | Array of search results \(id, threadId, subject, from, to, cc, bcc, replyTo, date, snippet, rfcMessageId, inReplyTo, references, internalDate\) | +| `nextPageToken` | string | Token for fetching the next page of results | +| `resultSizeEstimate` | number | Estimated total number of results | @@ -384,6 +398,251 @@ List all labels in a Gmail account +### Gmail Create Label + +Create a new label in Gmail + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | Display name for the new label | +| `messageListVisibility` | string | No | Visibility of messages with this label in the message list \(show or hide\) | +| `labelListVisibility` | string | No | Visibility of the label in the label list \(labelShow, labelShowIfUnread, or labelHide\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Label ID | +| `name` | string | Label display name | +| `messageListVisibility` | string | Visibility of messages with this label | +| `labelListVisibility` | string | Visibility of the label in the label list | +| `type` | string | Label type \(system or user\) | + + + +### Gmail Update Label + +Update a Gmail label in place (rename or change visibility) without recreating it + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `labelId` | string | Yes | ID of the label to update \(from Gmail List Labels\) | +| `name` | string | No | New display name for the label | +| `messageListVisibility` | string | No | Visibility of messages with this label in the message list \(show or hide\) | +| `labelListVisibility` | string | No | Visibility of the label in the label list \(labelShow, labelShowIfUnread, or labelHide\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Label ID | +| `name` | string | Label display name | +| `messageListVisibility` | string | Visibility of messages with this label | +| `labelListVisibility` | string | Visibility of the label in the label list | +| `type` | string | Label type \(system or user\) | + + + +### Gmail Delete Label + +Delete a label from Gmail + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `labelId` | string | Yes | ID of the label to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the label was successfully deleted | +| `labelId` | string | ID of the deleted label | + + + +### Gmail List Drafts + +List all drafts in a Gmail account + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `maxResults` | number | No | Maximum number of drafts to return \(default: 100, max: 500\) | +| `pageToken` | string | No | Page token for paginated results | +| `query` | string | No | Search query to filter drafts \(same syntax as Gmail search\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `drafts` | json | Array of draft objects with id, messageId, and threadId | +| `resultSizeEstimate` | number | Estimated total number of drafts | +| `nextPageToken` | string | Token for fetching the next page of results | + + + +### Gmail Get Draft + +Get a specific draft from Gmail by its ID + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `draftId` | string | Yes | ID of the draft to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Draft ID | +| `messageId` | string | Gmail message ID | +| `threadId` | string | Gmail thread ID | +| `to` | string | Recipient email address | +| `from` | string | Sender email address | +| `cc` | string | Cc recipients | +| `bcc` | string | Bcc recipients | +| `replyTo` | string | Reply-To header | +| `subject` | string | Draft subject | +| `date` | string | Date header | +| `rfcMessageId` | string | RFC Message-ID header \(not the Gmail message id\) | +| `inReplyTo` | string | In-Reply-To header | +| `references` | string | References header | +| `snippet` | string | Short excerpt of the draft text | +| `body` | string | Draft body text | +| `labelIds` | array | Draft labels | + + + +### Gmail Delete Draft + +Delete a specific draft from Gmail + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `draftId` | string | Yes | ID of the draft to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the draft was successfully deleted | +| `draftId` | string | ID of the deleted draft | + + + +### Gmail List Threads + +List email threads in a Gmail account + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `maxResults` | number | No | Maximum number of threads to return \(default: 100, max: 500\) | +| `pageToken` | string | No | Page token for paginated results | +| `query` | string | No | Search query to filter threads \(same syntax as Gmail search\) | +| `labelIds` | string | No | Comma-separated label IDs to filter threads by | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `threads` | json | Array of thread objects with id, snippet, and historyId | +| `resultSizeEstimate` | number | Estimated total number of threads | +| `nextPageToken` | string | Token for fetching the next page of results | + + + +### Gmail Get Thread + +Get a specific email thread from Gmail, including all messages in the thread + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `threadId` | string | Yes | ID of the thread to retrieve | +| `format` | string | No | Format to return the messages in \(full, metadata, or minimal\). Defaults to full. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Thread ID | +| `historyId` | string | History ID | +| `snippet` | string | Short excerpt of the thread | +| `messages` | json | Array of messages in the thread with id, threadId, labelIds, snippet, from, to, cc, bcc, replyTo, subject, date, rfcMessageId, inReplyTo, references, internalDate, and body | + + + +### Gmail Trash Thread + +Move an email thread to trash in Gmail + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `threadId` | string | Yes | ID of the thread to trash | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Thread ID | +| `trashed` | boolean | Whether the thread was successfully trashed | + + + +### Gmail Untrash Thread + +Remove an email thread from trash in Gmail + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `threadId` | string | Yes | ID of the thread to untrash | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Thread ID | +| `untrashed` | boolean | Whether the thread was successfully removed from trash | + + + ## Triggers @@ -420,6 +679,12 @@ Triggers when new emails are received in Gmail (requires Gmail credentials) | ↳ `from` | string | Sender email address | | ↳ `to` | string | Recipient email address | | ↳ `cc` | string | CC recipients | +| ↳ `bcc` | string | BCC recipients \(only present on messages you sent\) | +| ↳ `replyTo` | string | Reply-To header | +| ↳ `rfcMessageId` | string | RFC Message-ID header \(not the Gmail message ID\) | +| ↳ `inReplyTo` | string | In-Reply-To header \(RFC Message-ID of the parent message\) | +| ↳ `references` | string | References header \(RFC Message-IDs in the thread\) | +| ↳ `snippet` | string | Short excerpt of the message text | | ↳ `date` | string | Email date in ISO format | | ↳ `bodyText` | string | Plain text email body | | ↳ `bodyHtml` | string | HTML email body | diff --git a/apps/docs/content/docs/integrations/google-service-account.mdx b/apps/docs/content/docs/integrations/google-service-account.mdx index 4fcb62b7ba2..cf6d44731fc 100644 --- a/apps/docs/content/docs/integrations/google-service-account.mdx +++ b/apps/docs/content/docs/integrations/google-service-account.mdx @@ -11,7 +11,7 @@ Google service accounts can access shared resources directly or impersonate a Go For example, you could build a workflow that iterates through a list of employees, impersonates each one to read their Google Docs, and uploads the contents to a shared knowledge base — all without requiring any of those users to sign in. -For Google Cloud APIs such as BigQuery and Vertex AI, you can skip the JSON key entirely: see [Keyless Google Cloud access](#keyless-google-cloud-access). +For Google Cloud APIs such as BigQuery and Vertex AI, and for Gmail, Drive, Docs, Sheets, and Calendar through domain-wide delegation, you can skip the JSON key entirely: see [Keyless Google Cloud access](#keyless-google-cloud-access) and [Keyless domain-wide delegation](#keyless-domain-wide-delegation). ## Prerequisites @@ -173,6 +173,8 @@ Leave **Impersonated Account** blank to authenticate as the service account itse Domain-wide delegation requires a Google Workspace super administrator. It authorizes impersonation within that Workspace domain, subject to the authorized scopes and the impersonated user’s access. Consumer Gmail accounts and users outside that domain cannot be impersonated. Access also depends on the license required by the target service. See [Google’s service-account authorization guide](https://developers.google.com/identity/protocols/oauth2/service-account). +**Google Ads** never uses domain-wide delegation: add the service account's email as a user in the Google Ads account (**Admin > Access and security**); it signs in as itself. Enable the Google Ads API in the project that owns the service account; that project's Google Ads API access level applies. See [Google Ads](/integrations/google_ads). + A credential can serve multiple supported Google services when their APIs and scopes are configured. To rotate its key, create a replacement JSON key, update the credential in Sim, verify access, then delete the old key in Google Cloud. ## Keyless Google Cloud access [#keyless-google-cloud-access] @@ -180,7 +182,7 @@ A credential can serve multiple supported Google services when their APIs and sc A Google service account credential can also be **keyless**: Sim stores no key. Instead, your Google Cloud project trusts Sim through [Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers). On each run, Sim signs a short-lived token for this credential's own subject, exchanges it at Google's Security Token Service for a federated token, and uses that to impersonate your service account. The service account's IAM roles decide what workflows can do, exactly as with a JSON key. -Keyless credentials work with Google Cloud APIs that the service account calls as itself: **BigQuery**, and **Vertex AI** in every block that runs a Vertex model (pick the credential in its **Google Cloud Account** field). They can't use domain-wide delegation, so Gmail, Drive, Sheets, Calendar, and other Google Workspace blocks don't offer them, and a keyless credential refuses an **Impersonated Account**. Use a JSON key for those. +Keyless credentials work with Google Cloud APIs that the service account calls as itself: **BigQuery**, and **Vertex AI** in every block that runs a Vertex model (pick the credential in its **Google Cloud Account** field). With [keyless domain-wide delegation](#keyless-domain-wide-delegation) they also work with **Gmail**, **Google Drive**, **Google Docs**, **Google Sheets**, and **Google Calendar**, acting as one Workspace user. Other Google Workspace blocks need a JSON key. @@ -216,7 +218,7 @@ Keyless credentials work with Google Cloud APIs that the service account calls a --member="principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/subject/SUBJECT" ``` - The provider sets no allowed audiences, so it accepts its own resource name, which is the **Audience** the dialog shows. + By default the provider sets no allowed audiences, so it accepts its own resource name, which Sim's token carries as its `aud`. Save in Sim. Sim exchanges a token at STS and impersonates the service account; a token from that step proves the setup works. It also checks that a subject no credential holds can't impersonate the service account, so only this credential can use it. New IAM bindings can take a few minutes to apply; if saving fails right after you run the commands, wait and try again. @@ -227,18 +229,64 @@ Keyless credentials work with Google Cloud APIs that the service account calls a Keep the attribute condition and bind the role to the exact `principal://…/subject/SUBJECT` member. Without the condition, the provider accepts a token for any Sim subject, and a role granted to a `principalSet` for the whole pool would let other Sim credentials act with it. Sim refuses to save a service account that another subject can impersonate, but it can't see roles you grant the pool directly on other resources. +### Custom audience [#keyless-custom-audience] + +To pin the provider to a value of your own instead of its resource name, enter it under **Audience** (up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`). Sim's token then carries that value as its `aud`, and the setup commands add it to the provider's [allowed audiences](https://cloud.google.com/iam/docs/reference/rest/v1/projects.locations.workloadIdentityPools.providers#oidc), which then accept only the listed values: + +```bash +gcloud iam workload-identity-pools providers create-oidc PROVIDER_ID \ + ... \ + --allowed-audiences="AUDIENCE" +``` + +For an existing provider, run `gcloud iam workload-identity-pools providers update-oidc PROVIDER_ID --project="PROJECT_NUMBER" --location="global" --workload-identity-pool="POOL_ID" --allowed-audiences="AUDIENCE"` before reconnecting the credential with the new audience. Sim still exchanges the token at STS for the provider itself. To go back to the default, clear **Audience** and remove the allowed audiences from the provider (or add its resource name, `https://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID`). Another cloud's default audience, such as `sts.amazonaws.com`, is refused. + Each keyless credential has its own subject; reconnecting keeps it by default so your provider and binding keep working. Use one provider per credential (a pool can hold many). Keyless credentials are available for workspace credentials, not organization credentials. Sim uses impersonation rather than direct federated access because some Google Cloud APIs don't accept federated tokens, and impersonation keeps the same identity a JSON key would use. Self-hosted deployments need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issuer Google can reach over the public internet. See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments). ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject google-service-account`, trust the printed subject as above, then add `projectNumber`, `poolId`, `providerId`, and `serviceAccountEmail` to its `keylessConfig` and pass it to `sim credentials create google-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. As in the dialog, keyless works only for Google Cloud services such as BigQuery and Vertex AI, not Google Workspace blocks. +Run `sim credentials keyless-subject google-service-account`, trust the printed subject as above, then add `projectNumber`, `poolId`, `providerId`, and `serviceAccountEmail` (and optionally `audience`) to its `keylessConfig` and pass it to `sim credentials create google-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. For domain-wide delegation, also add `"delegation": {"user": "alice@yourcompany.com", "services": ["gmail", "google-drive"]}`, listing any of `gmail`, `google-drive`, `google-docs`, `google-sheets`, and `google-calendar`. + +### Keyless domain-wide delegation [#keyless-domain-wide-delegation] + +A keyless credential can also act as one Google Workspace user in Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar, without a JSON key. Google's [domain-wide delegation guidance](https://knowledge.workspace.google.com/admin/apps/domain-wide-delegation-best-practices) recommends this: on each run, Sim exchanges its token at STS as above, then asks the IAM Service Account Credentials API to [sign](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt) the same delegation assertion a JSON key would sign, with your user as its subject, and exchanges it at Google's OAuth token endpoint. + + + + Set up the keyless credential as above, from the Gmail, Google Drive, Google Docs, Google Sheets, or Google Calendar integration. Under **Domain-wide delegation**, choose the services the credential may use, and enter the **Workspace user** it acts as. + + + Grant the subject `roles/iam.serviceAccountTokenCreator` on the service account instead of `roles/iam.workloadIdentityUser`; the setup commands do this when delegation is on. Signing needs the `iam.serviceAccounts.signJwt` permission, which Service Account Token Creator includes and Workload Identity User does not. Token Creator also grants permissions Sim doesn't use, such as `iam.serviceAccounts.signBlob`; for least privilege, grant a [custom role](https://cloud.google.com/iam/docs/creating-custom-roles) with just `iam.serviceAccounts.signJwt` and `iam.serviceAccounts.getAccessToken` (the second keeps BigQuery and Vertex AI working with the same credential). + + + In the Google Cloud console, enable each service's API (see the [scopes reference](#scopes-reference)) in the project that owns the service account, and copy the service account's numeric **Unique ID** (its OAuth 2 client ID). + + + Have a Google Workspace super administrator authorize that client ID in the Admin console under **Security** → **Access and data control** → **API controls** → **Manage Domain Wide Delegation**, for the scopes the dialog lists under **Scopes to authorize**. + + + Save in Sim. For each service, Sim signs in as the Workspace user and makes one read (the Gmail profile, Drive's account details, or the first calendar in the calendar list), so a missing role or authorization, or a disabled Gmail, Drive, or Calendar API, shows up now rather than in a run. Google Docs and Google Sheets use Drive's scopes and are checked through the Drive API only: enable the Google Docs API and Google Sheets API yourself, since a disabled one shows up only in a run. + + + + +A keyless credential acts only as the Workspace user you entered. A workflow that sets a different **Impersonated Account** is refused; create one credential per user. To add a service or change the user, reconnect the credential and authorize any new scopes first; reconnecting opens on the credential's current user and services. + ### Troubleshooting keyless access -- **"Google Cloud refused Sim's token for this workload identity provider":** the project number, pool ID, or provider ID doesn't match; the provider is disabled, has allowed audiences set, or uses a different issuer; or its attribute condition doesn't allow this credential's subject. +- **"Google Cloud refused Sim's token for this workload identity provider":** the project number, pool ID, or provider ID doesn't match; the provider is disabled or uses a different issuer; its allowed audiences don't accept the credential's audience (with the default, it needs none set, or its own resource name among them; with a custom audience, that value must be listed); or its attribute condition doesn't allow this credential's subject. - **"Google Cloud could not fetch the signing keys from Sim's issuer":** the issuer isn't reachable over public HTTPS (self-hosted deployments). - **"The service account … does not let this credential impersonate it":** run the `add-iam-policy-binding` command with the exact member shown, and enable the IAM Service Account Credentials API. - **"This service account also trusts other Sim credentials":** remove any `principalSet` binding for the pool from the service account and keep the attribute condition. -- **A block fails with "This credential cannot be used here":** the block is a Google Workspace block or sets an **Impersonated Account**. Use a JSON key credential for it. +- **"… does not let this credential sign for it, which domain-wide delegation needs":** grant `roles/iam.serviceAccountTokenCreator` on the service account to the exact `principal://…/subject/SUBJECT` member, and enable the IAM Service Account Credentials API. +- **"Google Workspace has not authorized this service account's domain-wide delegation":** authorize the service account's client ID for every scope the dialog lists in the Admin console. New authorizations can take a while to apply. +- **"Your Google Workspace admin policies block one or more of these scopes":** a Workspace administrator must allow the service account's client ID under **API controls**, third-party app access. +- **"Google reports the service account … is disabled" (or "was deleted"):** enable the service account in Google Cloud under **IAM & Admin > Service Accounts**, or restore a deleted one within 30 days. +- **"Google refused to sign in as …":** the address isn't an active user in your Workspace domain. +- **"… but the Gmail API refused the request" (or Drive, Calendar):** enable that API in the project that owns the service account, and check the user has the service. +- **A block fails with "This credential cannot be used here":** the block needs scopes the credential's delegation doesn't authorize (or it isn't BigQuery or Vertex AI), or it sets an **Impersonated Account** other than the credential's Workspace user. Reconnect the credential with that block's service, or use a JSON key. + +A keyless credential is used only for the scopes listed under **Scopes to authorize**, and Sim refuses anything else before contacting Google. Like Google, Sim authorizes by scope, not by block: Drive, Docs, and Sheets share those scopes, so a credential delegated for any of them also serves the other two and their file pickers, which ask for read-only Drive access that the authorized `drive` scope covers. Read-only Gmail access, which the Gmail label picker and knowledge base connector ask for, is served with the authorized `gmail.modify` scope, and the Google Calendar knowledge base connector's read-only events access with the authorized `calendar` scope. Knowledge base connectors that need other scopes, such as the admin directory scopes, need a JSON key. diff --git a/apps/docs/content/docs/integrations/google_ads.mdx b/apps/docs/content/docs/integrations/google_ads.mdx index 284899ef02c..5f73ea3311e 100644 --- a/apps/docs/content/docs/integrations/google_ads.mdx +++ b/apps/docs/content/docs/integrations/google_ads.mdx @@ -14,6 +14,8 @@ Use [Google Ads](https://ads.google.com) in Sim to list accessible accounts and Connect your Google account and select a reporting operation. Google requires the `adwords` OAuth scope even for read-only reports; Sim's Google Ads operations do not create or modify ads, budgets, or billing. +To run without a person's Google sign-in, select a [Google service account](/integrations/google-service-account) JSON key instead. In Google Ads, open **Admin > Access and security** and add the service account's email as a user of each account it reads, or of the manager account above them; Google allows up to 20 accounts per email, and service accounts can't use Email only access. The service account signs in as itself, so leave domain-wide delegation off. API access then belongs to the Google Cloud project that owns the service account. Keyless (Workload Identity Federation) Google credentials can't be used with Google Ads. See [Google's service account guide](https://developers.google.com/google-ads/api/docs/oauth/service-accounts). + Google Ads API access belongs to the Google Cloud project that owns the OAuth client. Self-hosted deployments must enable the Google Ads API and configure the project's access level in the [Google Ads API overview](https://console.cloud.google.com/google/ads-apis/overview). Test access works only with Google Ads test accounts; production accounts require an appropriate production access level. [Developer tokens were retired on September 9, 2026](https://developers.google.com/google-ads/api/docs/api-policy/developer-token) and are no longer needed. Use the client account's Customer ID for reports. If your Google user accesses that account through a manager, set Manager Customer ID in the additional fields. List Customers returns accounts directly accessible to the signed-in user, which may include the manager rather than its client accounts. Test accounts do not serve ads and cannot demonstrate real impressions, clicks, or spend. diff --git a/apps/docs/content/docs/integrations/google_calendar.mdx b/apps/docs/content/docs/integrations/google_calendar.mdx index 726ce527d4d..25044675261 100644 --- a/apps/docs/content/docs/integrations/google_calendar.mdx +++ b/apps/docs/content/docs/integrations/google_calendar.mdx @@ -52,22 +52,39 @@ Create a new event in Google Calendar. | `endDateTime` | string | Yes | End time. Use a datetime with timezone offset \(2025-06-03T11:00:00-08:00\) or a date \(2025-06-04\) for an all-day event | | `timeZone` | string | No | IANA time zone \(e.g., America/Los_Angeles\). Used as-is when provided. For recurring events a time zone is required to expand the recurrence correctly; for one-off events it is only needed when the datetime omits a UTC offset \(a naive datetime defaults to America/Los_Angeles\). | | `attendees` | array | No | Array of attendee email addresses | +| `optionalAttendees` | array | No | Array of attendee email addresses to invite as optional guests \(comma-separated string also accepted\) | | `recurrence` | string | No | Recurrence rule\(s\) in RFC 5545 format \(e.g., RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\). Separate multiple rules with newlines. | | `addGoogleMeet` | boolean | No | Attach a Google Meet video conference link to the event | | `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none | +| `visibility` | string | No | Event visibility: default, public, private, or confidential | +| `transparency` | string | No | Show as: opaque \(busy, the default\) or transparent \(free\) | +| `colorId` | string | No | Event color ID \("1" to "11", from the Calendar colors endpoint\) | +| `reminders` | json | No | Custom reminders replacing the calendar default, as a JSON array of up to 5 \{ method: "email" \| "popup", minutes \} objects \(e.g., \[\{"method":"popup","minutes":10\}\]\). Pass \[\] for no reminders. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | +| `hangoutLink` | string | Google Meet link | +| `conferenceData` | json | Conference details: conferenceId, conferenceSolution \{ name, key.type \}, entryPoints \[\{ entryPointType \(video, phone, sip, more\), uri, label, pin, meetingCode, passcode \}\], notes | +| `recurrence` | json | Recurrence rules | +| `recurringEventId` | string | For an instance of a recurring event, the ID of the recurring series | +| `originalStartTime` | json | For an instance of a recurring event, its original start time in the series | +| `reminders` | json | Reminders: useDefault and overrides \[\{ method \(email or popup\), minutes \}\] | +| `visibility` | string | Visibility: default, public, private, or confidential | +| `transparency` | string | Show as: opaque \(busy\) or transparent \(free\) | +| `colorId` | string | Event color ID | +| `eventType` | string | Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, or birthday | +| `iCalUID` | string | iCalendar UID \(RFC 5545\), shared across calendars | +| `created` | string | Creation time \(RFC3339\) | +| `updated` | string | Last modification time \(RFC3339\) | +| `attachments` | json | File attachments \[\{ fileUrl, title, mimeType, iconLink, fileId \}\] | | `id` | string | Event ID | | `htmlLink` | string | Event link | -| `hangoutLink` | string | Google Meet link | | `status` | string | Event status | | `summary` | string | Event title | | `description` | string | Event description | | `location` | string | Event location | -| `recurrence` | json | Recurrence rules | | `start` | json | Event start | | `end` | json | Event end | | `attendees` | json | Event attendees | @@ -93,14 +110,42 @@ List events from Google Calendar. | `maxResults` | number | No | Maximum number of events to return \(max 2500\) | | `pageToken` | string | No | Token for retrieving the next page of results | | `orderBy` | string | No | Order of events: startTime \(chronological, the default\) or updated \(last-modified\). startTime is always valid here because singleEvents is set. | +| `timeZone` | string | No | IANA time zone used for times in the response \(e.g., America/New_York\). Defaults to the calendar's time zone. | +| `updatedMin` | string | No | Only return events modified at or after this time \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\). Events deleted since then are always included. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `nextPageToken` | string | Next page token | +| `nextSyncToken` | string | Sync token for incremental sync, returned on the last page | | `timeZone` | string | Calendar time zone | -| `events` | json | List of events | +| `events` | array | List of events | +| ↳ `hangoutLink` | string | Google Meet link | +| ↳ `conferenceData` | json | Conference details: conferenceId, conferenceSolution \{ name, key.type \}, entryPoints \[\{ entryPointType \(video, phone, sip, more\), uri, label, pin, meetingCode, passcode \}\], notes | +| ↳ `recurrence` | json | Recurrence rules \(RRULE/EXRULE/RDATE/EXDATE\) | +| ↳ `recurringEventId` | string | For an instance of a recurring event, the ID of the recurring series | +| ↳ `originalStartTime` | json | For an instance of a recurring event, its original start time in the series | +| ↳ `reminders` | json | Reminders: useDefault and overrides \[\{ method \(email or popup\), minutes \}\] | +| ↳ `visibility` | string | Visibility: default, public, private, or confidential | +| ↳ `transparency` | string | Show as: opaque \(busy\) or transparent \(free\) | +| ↳ `colorId` | string | Event color ID | +| ↳ `eventType` | string | Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, or birthday | +| ↳ `iCalUID` | string | iCalendar UID \(RFC 5545\), shared across calendars | +| ↳ `created` | string | Creation time \(RFC3339\) | +| ↳ `updated` | string | Last modification time \(RFC3339\) | +| ↳ `attachments` | json | File attachments \[\{ fileUrl, title, mimeType, iconLink, fileId \}\] | +| ↳ `id` | string | Event ID | +| ↳ `htmlLink` | string | Event link | +| ↳ `status` | string | Event status \(confirmed, tentative, cancelled\) | +| ↳ `summary` | string | Event title | +| ↳ `description` | string | Event description | +| ↳ `location` | string | Event location | +| ↳ `start` | json | Event start \{ dateTime \| date, timeZone \} | +| ↳ `end` | json | Event end \{ dateTime \| date, timeZone \} | +| ↳ `attendees` | json | Attendees \[\{ email, displayName, responseStatus \(needsAction, declined, tentative, accepted\), optional, organizer, self, resource, comment \}\] | +| ↳ `creator` | json | Event creator \{ email, displayName, self \} | +| ↳ `organizer` | json | Event organizer \{ email, displayName, self \} | @@ -121,6 +166,20 @@ Get a specific event from Google Calendar. | Parameter | Type | Description | | --------- | ---- | ----------- | +| `hangoutLink` | string | Google Meet link | +| `conferenceData` | json | Conference details: conferenceId, conferenceSolution \{ name, key.type \}, entryPoints \[\{ entryPointType \(video, phone, sip, more\), uri, label, pin, meetingCode, passcode \}\], notes | +| `recurrence` | json | Recurrence rules \(RRULE/EXRULE/RDATE/EXDATE\) | +| `recurringEventId` | string | For an instance of a recurring event, the ID of the recurring series | +| `originalStartTime` | json | For an instance of a recurring event, its original start time in the series | +| `reminders` | json | Reminders: useDefault and overrides \[\{ method \(email or popup\), minutes \}\] | +| `visibility` | string | Visibility: default, public, private, or confidential | +| `transparency` | string | Show as: opaque \(busy\) or transparent \(free\) | +| `colorId` | string | Event color ID | +| `eventType` | string | Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, or birthday | +| `iCalUID` | string | iCalendar UID \(RFC 5545\), shared across calendars | +| `created` | string | Creation time \(RFC3339\) | +| `updated` | string | Last modification time \(RFC3339\) | +| `attachments` | json | File attachments \[\{ fileUrl, title, mimeType, iconLink, fileId \}\] | | `id` | string | Event ID | | `htmlLink` | string | Event link | | `status` | string | Event status | @@ -157,19 +216,35 @@ Update an existing event in Google Calendar. | `recurrence` | string | No | Recurrence rule\(s\) in RFC 5545 format \(e.g., RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\). Separate multiple rules with newlines. When provided, replaces the event's recurrence; leaving it empty keeps the existing recurrence unchanged. Requires a timeZone for timed events. | | `addGoogleMeet` | boolean | No | Attach a Google Meet video conference link to the event | | `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none | +| `visibility` | string | No | Event visibility: default, public, private, or confidential | +| `transparency` | string | No | Show as: opaque \(busy, the default\) or transparent \(free\) | +| `colorId` | string | No | Event color ID \("1" to "11", from the Calendar colors endpoint\) | +| `reminders` | json | No | Custom reminders replacing the calendar default, as a JSON array of up to 5 \{ method: "email" \| "popup", minutes \} objects \(e.g., \[\{"method":"popup","minutes":10\}\]\). Pass \[\] for no reminders. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | +| `hangoutLink` | string | Google Meet link | +| `conferenceData` | json | Conference details: conferenceId, conferenceSolution \{ name, key.type \}, entryPoints \[\{ entryPointType \(video, phone, sip, more\), uri, label, pin, meetingCode, passcode \}\], notes | +| `recurrence` | json | Recurrence rules | +| `recurringEventId` | string | For an instance of a recurring event, the ID of the recurring series | +| `originalStartTime` | json | For an instance of a recurring event, its original start time in the series | +| `reminders` | json | Reminders: useDefault and overrides \[\{ method \(email or popup\), minutes \}\] | +| `visibility` | string | Visibility: default, public, private, or confidential | +| `transparency` | string | Show as: opaque \(busy\) or transparent \(free\) | +| `colorId` | string | Event color ID | +| `eventType` | string | Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, or birthday | +| `iCalUID` | string | iCalendar UID \(RFC 5545\), shared across calendars | +| `created` | string | Creation time \(RFC3339\) | +| `updated` | string | Last modification time \(RFC3339\) | +| `attachments` | json | File attachments \[\{ fileUrl, title, mimeType, iconLink, fileId \}\] | | `id` | string | Event ID | | `htmlLink` | string | Event link | -| `hangoutLink` | string | Google Meet link | | `status` | string | Event status | | `summary` | string | Event title | | `description` | string | Event description | | `location` | string | Event location | -| `recurrence` | json | Recurrence rules | | `start` | json | Event start | | `end` | json | Event end | | `attendees` | json | Event attendees | @@ -220,6 +295,20 @@ Move an event to a different calendar. | Parameter | Type | Description | | --------- | ---- | ----------- | +| `hangoutLink` | string | Google Meet link | +| `conferenceData` | json | Conference details: conferenceId, conferenceSolution \{ name, key.type \}, entryPoints \[\{ entryPointType \(video, phone, sip, more\), uri, label, pin, meetingCode, passcode \}\], notes | +| `recurrence` | json | Recurrence rules \(RRULE/EXRULE/RDATE/EXDATE\) | +| `recurringEventId` | string | For an instance of a recurring event, the ID of the recurring series | +| `originalStartTime` | json | For an instance of a recurring event, its original start time in the series | +| `reminders` | json | Reminders: useDefault and overrides \[\{ method \(email or popup\), minutes \}\] | +| `visibility` | string | Visibility: default, public, private, or confidential | +| `transparency` | string | Show as: opaque \(busy\) or transparent \(free\) | +| `colorId` | string | Event color ID | +| `eventType` | string | Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, or birthday | +| `iCalUID` | string | iCalendar UID \(RFC 5545\), shared across calendars | +| `created` | string | Creation time \(RFC3339\) | +| `updated` | string | Last modification time \(RFC3339\) | +| `attachments` | json | File attachments \[\{ fileUrl, title, mimeType, iconLink, fileId \}\] | | `id` | string | Event ID | | `htmlLink` | string | Event link | | `status` | string | Event status | @@ -314,6 +403,20 @@ Create events from natural language text. | Parameter | Type | Description | | --------- | ---- | ----------- | +| `hangoutLink` | string | Google Meet link | +| `conferenceData` | json | Conference details: conferenceId, conferenceSolution \{ name, key.type \}, entryPoints \[\{ entryPointType \(video, phone, sip, more\), uri, label, pin, meetingCode, passcode \}\], notes | +| `recurrence` | json | Recurrence rules \(RRULE/EXRULE/RDATE/EXDATE\) | +| `recurringEventId` | string | For an instance of a recurring event, the ID of the recurring series | +| `originalStartTime` | json | For an instance of a recurring event, its original start time in the series | +| `reminders` | json | Reminders: useDefault and overrides \[\{ method \(email or popup\), minutes \}\] | +| `visibility` | string | Visibility: default, public, private, or confidential | +| `transparency` | string | Show as: opaque \(busy\) or transparent \(free\) | +| `colorId` | string | Event color ID | +| `eventType` | string | Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, or birthday | +| `iCalUID` | string | iCalendar UID \(RFC 5545\), shared across calendars | +| `created` | string | Creation time \(RFC3339\) | +| `updated` | string | Last modification time \(RFC3339\) | +| `attachments` | json | File attachments \[\{ fileUrl, title, mimeType, iconLink, fileId \}\] | | `id` | string | Event ID | | `htmlLink` | string | Event link | | `status` | string | Event status | @@ -348,6 +451,20 @@ Invite attendees to an existing Google Calendar event. | Parameter | Type | Description | | --------- | ---- | ----------- | +| `hangoutLink` | string | Google Meet link | +| `conferenceData` | json | Conference details: conferenceId, conferenceSolution \{ name, key.type \}, entryPoints \[\{ entryPointType \(video, phone, sip, more\), uri, label, pin, meetingCode, passcode \}\], notes | +| `recurrence` | json | Recurrence rules \(RRULE/EXRULE/RDATE/EXDATE\) | +| `recurringEventId` | string | For an instance of a recurring event, the ID of the recurring series | +| `originalStartTime` | json | For an instance of a recurring event, its original start time in the series | +| `reminders` | json | Reminders: useDefault and overrides \[\{ method \(email or popup\), minutes \}\] | +| `visibility` | string | Visibility: default, public, private, or confidential | +| `transparency` | string | Show as: opaque \(busy\) or transparent \(free\) | +| `colorId` | string | Event color ID | +| `eventType` | string | Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, or birthday | +| `iCalUID` | string | iCalendar UID \(RFC 5545\), shared across calendars | +| `created` | string | Creation time \(RFC3339\) | +| `updated` | string | Last modification time \(RFC3339\) | +| `attachments` | json | File attachments \[\{ fileUrl, title, mimeType, iconLink, fileId \}\] | | `id` | string | Event ID | | `htmlLink` | string | Event link | | `status` | string | Event status | @@ -382,16 +499,33 @@ RSVP to a Google Calendar event (accept, decline, or tentative) as the connected | Parameter | Type | Description | | --------- | ---- | ----------- | +| `hangoutLink` | string | Google Meet link | +| `conferenceData` | json | Conference details: conferenceId, conferenceSolution \{ name, key.type \}, entryPoints \[\{ entryPointType \(video, phone, sip, more\), uri, label, pin, meetingCode, passcode \}\], notes | +| `recurrence` | json | Recurrence rules \(RRULE/EXRULE/RDATE/EXDATE\) | +| `recurringEventId` | string | For an instance of a recurring event, the ID of the recurring series | +| `originalStartTime` | json | For an instance of a recurring event, its original start time in the series | +| `reminders` | json | Reminders: useDefault and overrides \[\{ method \(email or popup\), minutes \}\] | +| `visibility` | string | Visibility: default, public, private, or confidential | +| `transparency` | string | Show as: opaque \(busy\) or transparent \(free\) | +| `colorId` | string | Event color ID | +| `eventType` | string | Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, or birthday | +| `iCalUID` | string | iCalendar UID \(RFC 5545\), shared across calendars | +| `created` | string | Creation time \(RFC3339\) | +| `updated` | string | Last modification time \(RFC3339\) | +| `attachments` | json | File attachments \[\{ fileUrl, title, mimeType, iconLink, fileId \}\] | | `id` | string | Event ID | | `htmlLink` | string | Event link | | `status` | string | Event status | | `summary` | string | Event title | +| `description` | string | Event description | +| `location` | string | Event location | | `start` | json | Event start | | `end` | json | Event end | | `responseStatus` | string | Your confirmed response \(accepted, declined, or tentative\) | | `comment` | string | Your response comment | | `attendees` | json | Event attendees | | `organizer` | json | Event organizer | +| `creator` | json | Event creator | @@ -636,9 +770,16 @@ Triggers when events are created, updated, or cancelled in Google Calendar | ↳ `end` | json | Event end time | | ↳ `created` | string | Event creation time | | ↳ `updated` | string | Event last updated time | -| ↳ `attendees` | json | Event attendees | -| ↳ `creator` | json | Event creator | -| ↳ `organizer` | json | Event organizer | +| ↳ `attendees` | json | Event attendees \[\{ email, displayName, responseStatus \(needsAction, declined, tentative, accepted\), optional, organizer, self \}\] | +| ↳ `creator` | json | Event creator \{ email, displayName, self \} | +| ↳ `organizer` | json | Event organizer \{ email, displayName, self \} | +| ↳ `hangoutLink` | string | Google Meet link | +| ↳ `conferenceData` | json | Conference details: conferenceId, conferenceSolution \{ name \}, entryPoints \[\{ entryPointType, uri, label, pin, meetingCode, passcode \}\] | +| ↳ `recurringEventId` | string | Recurring series ID when the event is an instance of a recurring event | +| ↳ `originalStartTime` | json | Original start time of a recurring event instance | +| ↳ `visibility` | string | Visibility: default, public, private, or confidential | +| ↳ `transparency` | string | Show as: opaque \(busy\) or transparent \(free\) | +| ↳ `iCalUID` | string | iCalendar UID | | `calendarId` | string | Calendar ID | | `timestamp` | string | Event processing timestamp in ISO format | diff --git a/apps/docs/content/docs/integrations/google_contacts.mdx b/apps/docs/content/docs/integrations/google_contacts.mdx index 90220a0443a..e351573043e 100644 --- a/apps/docs/content/docs/integrations/google_contacts.mdx +++ b/apps/docs/content/docs/integrations/google_contacts.mdx @@ -53,13 +53,14 @@ Create a new contact in Google Contacts | `organization` | string | No | Organization/company name | | `jobTitle` | string | No | Job title at the organization | | `notes` | string | No | Notes or biography for the contact | +| `birthday` | string | No | Birthday as YYYY-MM-DD, or MM-DD when the year is unknown | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `content` | string | Contact creation confirmation message | -| `metadata` | json | Created contact metadata including resource name and details | +| `metadata` | json | Created contact \(resourceName, etag, displayName, givenName, familyName, middleName, emails, phones, organizations, addresses, biographies, urls, photos, birthdays, events, nicknames, relations, userDefined, contactGroupResourceNames\) | @@ -80,7 +81,7 @@ Get a specific contact from Google Contacts | Parameter | Type | Description | | --------- | ---- | ----------- | | `content` | string | Contact retrieval confirmation message | -| `metadata` | json | Contact details including name, email, phone, and organization | +| `metadata` | json | Contact details \(resourceName, etag, displayName, givenName, familyName, middleName, emails, phones, organizations, addresses, biographies, urls, photos, birthdays, events, nicknames, relations, userDefined, contactGroupResourceNames\) | @@ -103,7 +104,7 @@ List contacts from Google Contacts | Parameter | Type | Description | | --------- | ---- | ----------- | | `content` | string | Summary of found contacts count | -| `metadata` | json | List of contacts with pagination tokens | +| `metadata` | json | totalItems, nextPageToken, and contacts: \[\{resourceName, etag, displayName, givenName, familyName, middleName, emails, phones, organizations, addresses, biographies, urls, photos, birthdays, events, nicknames, relations, userDefined, contactGroupResourceNames\}\] | @@ -125,7 +126,7 @@ Search contacts in Google Contacts by name, email, phone, or organization | Parameter | Type | Description | | --------- | ---- | ----------- | | `content` | string | Summary of search results count | -| `metadata` | json | Search results with matching contacts | +| `metadata` | json | contacts: \[\{resourceName, etag, displayName, givenName, familyName, middleName, emails, phones, organizations, addresses, biographies, urls, photos, birthdays, events, nicknames, relations, userDefined, contactGroupResourceNames\}\] | @@ -150,13 +151,14 @@ Update an existing contact in Google Contacts | `organization` | string | No | Updated organization/company name | | `jobTitle` | string | No | Updated job title | | `notes` | string | No | Updated notes or biography | +| `birthday` | string | No | Birthday as YYYY-MM-DD, or MM-DD when the year is unknown | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `content` | string | Contact update confirmation message | -| `metadata` | json | Updated contact metadata | +| `metadata` | json | Updated contact \(resourceName, etag, displayName, givenName, familyName, middleName, emails, phones, organizations, addresses, biographies, urls, photos, birthdays, events, nicknames, relations, userDefined, contactGroupResourceNames\) | diff --git a/apps/docs/content/docs/integrations/google_docs.mdx b/apps/docs/content/docs/integrations/google_docs.mdx index 1b1da760614..7e6cd24826d 100644 --- a/apps/docs/content/docs/integrations/google_docs.mdx +++ b/apps/docs/content/docs/integrations/google_docs.mdx @@ -46,6 +46,8 @@ Read content from a Google Docs document | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | Google Docs document ID | +| `includeTabsContent` | boolean | No | When true, reads every tab of the document: content holds the text of all tabs in order and tabs lists each tab with its own text. Default: false \(only the first tab is read\). | +| `suggestionsViewMode` | string | No | How suggested edits appear in the returned text. One of: DEFAULT_FOR_CURRENT_ACCESS \(default\), SUGGESTIONS_INLINE, PREVIEW_SUGGESTIONS_ACCEPTED, PREVIEW_WITHOUT_SUGGESTIONS. | #### Output @@ -57,6 +59,14 @@ Read content from a Google Docs document | ↳ `title` | string | Document title | | ↳ `mimeType` | string | Document MIME type | | ↳ `url` | string | Document URL | +| ↳ `revisionId` | string | Revision ID of the document as read | +| `tabs` | array | Every tab in the document, depth-first \(populated only when includeTabsContent is true\) | +| ↳ `tabId` | string | Tab ID | +| ↳ `title` | string | Tab title | +| ↳ `parentTabId` | string | Parent tab ID | +| ↳ `index` | number | Zero-based index within the parent | +| ↳ `nestingLevel` | number | Depth of the tab | +| ↳ `content` | string | Extracted text content of the tab | @@ -72,6 +82,7 @@ Append content to a Google Docs document. Content is inserted literally; Markdow | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to write to | | `content` | string | Yes | The content to write to the document | +| `tabId` | string | No | ID of the tab to append the content to \(from Read with includeTabsContent\). When omitted, the first tab is used. | #### Output @@ -127,6 +138,7 @@ Insert text at a specific index in a Google Docs document. When no index is prov | `documentId` | string | Yes | The ID of the document to insert text into | | `text` | string | Yes | The text to insert | | `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the text. When omitted, text is appended to the end of the document. | +| `tabId` | string | No | ID of the tab to insert the text into \(from Read with includeTabsContent\). When omitted, the first tab is used. | #### Output @@ -155,6 +167,8 @@ Replace all occurrences of a search string with new text across a Google Docs do | `searchText` | string | Yes | The text to find | | `replaceText` | string | No | The text to replace matches with. Use an empty string to delete matches. | | `matchCase` | boolean | No | Whether the search should be case sensitive. Defaults to false. | +| `searchByRegex` | boolean | No | When true, the search text is treated as a regular expression. Defaults to false. | +| `tabId` | string | No | ID of a tab to limit the replacement to \(from Read with includeTabsContent\). When omitted, matches are replaced in all tabs. | #### Output @@ -183,6 +197,7 @@ Insert an empty table with the given number of rows and columns into a Google Do | `rows` | number | Yes | The number of rows in the table | | `columns` | number | Yes | The number of columns in the table | | `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the table. When omitted, the table is appended to the end of the document. | +| `tabId` | string | No | ID of the tab to insert the table into \(from Read with includeTabsContent\). When omitted, the first tab is used. | #### Output @@ -212,6 +227,7 @@ Insert an inline image from a public URL into a Google Docs document. The image | `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the image. When omitted, the image is appended to the end of the document. | | `width` | number | No | Optional image width in points \(PT\) | | `height` | number | No | Optional image height in points \(PT\) | +| `tabId` | string | No | ID of the tab to insert the image into \(from Read with includeTabsContent\). When omitted, the first tab is used. | #### Output @@ -238,6 +254,7 @@ Insert a page break into a Google Docs document. When no index is provided, the | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to insert the page break into | | `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the page break. When omitted, the page break is appended to the end of the document. | +| `tabId` | string | No | ID of the tab to insert the page break into \(from Read with includeTabsContent\). When omitted, the first tab is used. | #### Output diff --git a/apps/docs/content/docs/integrations/google_drive.mdx b/apps/docs/content/docs/integrations/google_drive.mdx index f70b5e1c0c0..5921d580707 100644 --- a/apps/docs/content/docs/integrations/google_drive.mdx +++ b/apps/docs/content/docs/integrations/google_drive.mdx @@ -50,6 +50,8 @@ List files and folders in Google Drive with complete metadata | `query` | string | No | Search term to filter files by name \(e.g. "budget" finds files with "budget" in the name\). Do NOT use Google Drive query syntax here - just provide a plain search term. | | `pageSize` | number | No | The maximum number of files to return \(default: 100\) | | `pageToken` | string | No | The page token to use for pagination | +| `orderBy` | string | No | Comma-separated sort keys: createdTime, folder, modifiedByMeTime, modifiedTime, name, name_natural, quotaBytesUsed, recency, sharedWithMeTime, starred, viewedByMeTime. Append " desc" to reverse \(e.g., "modifiedTime desc"\) | +| `driveId` | string | No | ID of a shared drive to restrict results to \(default: search My Drive and all shared drives\) | #### Output @@ -109,6 +111,7 @@ List files and folders in Google Drive with complete metadata | ↳ `contentRestrictions` | json | Content restrictions | | ↳ `linkShareMetadata` | json | Link share metadata | | `nextPageToken` | string | Page token for the next page of files; absent from the response when the end of the files list has been reached | +| `incompleteSearch` | boolean | Whether the search was incomplete and some results may be missing \(can happen when searching all drives; set driveId to narrow the search\) | @@ -152,6 +155,11 @@ Get metadata for a specific file in Google Drive by its ID | ↳ `capabilities` | json | User capabilities on file | | ↳ `md5Checksum` | string | MD5 hash | | ↳ `version` | string | Version number | +| ↳ `driveId` | string | ID of the shared drive the file is in | +| ↳ `fileExtension` | string | File extension | +| ↳ `exportLinks` | json | Export format links for Google Workspace files | +| ↳ `sharingUser` | json | User who shared the file with the current user | +| ↳ `headRevisionId` | string | Head revision ID | @@ -521,6 +529,8 @@ Search for files in Google Drive using advanced query syntax (e.g., fullText con | `query` | string | Yes | Google Drive query string using advanced search syntax \(e.g., "fullText contains 'budget'", "mimeType = 'application/pdf'", "modifiedTime > '2024-01-01'"\) | | `pageSize` | number | No | Maximum number of files to return \(default: 100\) | | `pageToken` | string | No | Token for fetching the next page of results | +| `orderBy` | string | No | Comma-separated sort keys: createdTime, folder, modifiedByMeTime, modifiedTime, name, name_natural, quotaBytesUsed, recency, sharedWithMeTime, starred, viewedByMeTime. Append " desc" to reverse \(e.g., "modifiedTime desc"\). Ignored for fullText queries, which Drive always orders by relevance | +| `driveId` | string | No | ID of a shared drive to restrict results to \(default: search My Drive and all shared drives\) | #### Output @@ -554,6 +564,7 @@ Search for files in Google Drive using advanced query syntax (e.g., fullText con | ↳ `capabilities` | json | User capabilities on file | | ↳ `version` | string | Version number | | `nextPageToken` | string | Page token for the next page of files; absent from the response when the end of the files list has been reached | +| `incompleteSearch` | boolean | Whether the search was incomplete and some results may be missing \(can happen when searching all drives; set driveId to narrow the search\) | @@ -685,6 +696,8 @@ Share a file with a user, group, domain, or make it public | `moveToNewOwnersRoot` | boolean | No | When transferring ownership, move the file to the new owner's My Drive root folder. | | `sendNotification` | boolean | No | Whether to send an email notification \(default: true\) | | `emailMessage` | string | No | Custom message to include in the notification email | +| `expirationTime` | string | No | RFC 3339 time when the permission expires \(e.g., 2025-12-31T23:59:59Z\). Only for user and group permissions; must be in the future and at most one year out | +| `allowFileDiscovery` | boolean | No | Whether the file can be discovered through search. Only for domain and anyone permissions | #### Output @@ -699,6 +712,8 @@ Share a file with a user, group, domain, or make it public | ↳ `domain` | string | Domain of the grantee | | ↳ `expirationTime` | string | Expiration time | | ↳ `deleted` | boolean | Whether grantee is deleted | +| ↳ `allowFileDiscovery` | boolean | Whether the file is discoverable through search \(domain and anyone only\) | +| ↳ `pendingOwner` | boolean | Whether the grantee is a pending owner of the file | @@ -737,6 +752,7 @@ List all permissions (who has access) for a file in Google Drive | --------- | ---- | -------- | ----------- | | `fileId` | string | Yes | The ID of the file to list permissions for | | `pageToken` | string | No | The page token to use for pagination | +| `pageSize` | number | No | Maximum number of permissions to return \(1-100\). Omit to return the full list for My Drive files and up to 100 for shared drive files | #### Output diff --git a/apps/docs/content/docs/integrations/google_forms.mdx b/apps/docs/content/docs/integrations/google_forms.mdx index e72ba1e3319..8f606fa2d50 100644 --- a/apps/docs/content/docs/integrations/google_forms.mdx +++ b/apps/docs/content/docs/integrations/google_forms.mdx @@ -61,12 +61,16 @@ Retrieve a single response or list responses from a Google Form | ↳ `createTime` | string | When the response was created | | ↳ `lastSubmittedTime` | string | When the response was last submitted | | ↳ `answers` | json | Map of question IDs to answer values | +| ↳ `respondentEmail` | string | Respondent email address \(only when the form collects emails\) | +| ↳ `totalScore` | number | Total points awarded \(only for graded quizzes\) | | `nextPageToken` | string | Token to fetch the next page of responses \(null when no more pages\) | | `response` | object | Single form response \(when responseId is provided\) | | ↳ `responseId` | string | Unique response ID | | ↳ `createTime` | string | When the response was created | | ↳ `lastSubmittedTime` | string | When the response was last submitted | | ↳ `answers` | json | Map of question IDs to answer values | +| ↳ `respondentEmail` | string | Respondent email address \(only when the form collects emails\) | +| ↳ `totalScore` | number | Total points awarded \(only for graded quizzes\) | | `raw` | json | Raw API response data | @@ -335,6 +339,7 @@ Trigger workflow from Google Form submissions (via Apps Script forwarder) | `createTime` | string | Response creation timestamp | | `lastSubmittedTime` | string | Last submitted timestamp | | `formId` | string | Google Form ID | +| `respondentEmail` | string | Respondent email address \(only when the form collects emails and the forwarder script sends it\) | | `answers` | object | Normalized map of question -> answer | | `raw` | object | Original payload \(when enabled\) | diff --git a/apps/docs/content/docs/integrations/google_groups.mdx b/apps/docs/content/docs/integrations/google_groups.mdx index 7adad944561..6267b1a1800 100644 --- a/apps/docs/content/docs/integrations/google_groups.mdx +++ b/apps/docs/content/docs/integrations/google_groups.mdx @@ -52,12 +52,15 @@ List all groups in a Google Workspace domain | `maxResults` | number | No | Maximum number of results to return \(1-200\). Example: 50 | | `pageToken` | string | No | Token for fetching the next page of results | | `query` | string | No | Search query to filter groups \(e.g., "email:admin*"\) | +| `userKey` | string | No | Only list groups this user or group is a direct member of \(email or immutable ID\). Cannot be combined with customer | +| `orderBy` | string | No | Column to sort results by. Only "email" is supported | +| `sortOrder` | string | No | Sort direction when orderBy is set: ASCENDING or DESCENDING | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `groups` | json | Array of group objects | +| `groups` | json | Array of group objects \(id, email, name, description, directMembersCount, adminCreated, aliases, nonEditableAliases, etag, kind\) | | `nextPageToken` | string | Token for fetching next page of results | @@ -161,12 +164,13 @@ List all members of a Google Group | `maxResults` | number | No | Maximum number of results to return \(1-200\). Example: 50 | | `pageToken` | string | No | Token for fetching the next page of results | | `roles` | string | No | Filter by roles \(comma-separated: OWNER, MANAGER, MEMBER\) | +| `includeDerivedMembership` | boolean | No | Whether to also list indirect members inherited through nested groups \(default false\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `members` | json | Array of member objects | +| `members` | json | Array of member objects \(id, email, role, type, status, etag, kind\) | | `nextPageToken` | string | Token for fetching next page of results | @@ -365,6 +369,7 @@ Get the settings for a Google Group including access permissions, moderation, an | Parameter | Type | Description | | --------- | ---- | ----------- | +| `settings` | json | All returned group settings as one object, with the same fields as the top-level outputs \(email, name, description, whoCanJoin, whoCanViewMembership, whoCanViewGroup, whoCanPostMessage, allowExternalMembers, ..., defaultSender\) | | `email` | string | The group's email address | | `name` | string | The group name \(max 75 characters\) | | `description` | string | The group description \(max 4096 characters\) | @@ -449,6 +454,7 @@ Update the settings for a Google Group including access permissions, moderation, | Parameter | Type | Description | | --------- | ---- | ----------- | +| `settings` | json | All returned group settings as one object, with the same fields as the top-level outputs \(email, name, description, whoCanJoin, whoCanViewMembership, whoCanViewGroup, whoCanPostMessage, allowExternalMembers, ..., defaultSender\) | | `email` | string | The group's email address | | `name` | string | The group name | | `description` | string | The group description | diff --git a/apps/docs/content/docs/integrations/google_meet.mdx b/apps/docs/content/docs/integrations/google_meet.mdx index 87a35b8f48d..aff7852ad50 100644 --- a/apps/docs/content/docs/integrations/google_meet.mdx +++ b/apps/docs/content/docs/integrations/google_meet.mdx @@ -52,6 +52,9 @@ Create a new Google Meet meeting space | Parameter | Type | Description | | --------- | ---- | ----------- | +| `moderation` | string | Host moderation: ON or OFF | +| `attendanceReportGenerationType` | string | Attendance report: GENERATE_REPORT or DO_NOT_GENERATE | +| `artifactConfig` | json | Auto-generated artifacts: recordingConfig.autoRecordingGeneration, transcriptionConfig.autoTranscriptionGeneration, smartNotesConfig.autoSmartNotesGeneration \(ON or OFF\) | | `name` | string | Resource name of the space \(e.g., spaces/abc123\) | | `meetingUri` | string | Meeting URL \(e.g., https://meet.google.com/abc-defg-hij) | | `meetingCode` | string | Meeting code \(e.g., abc-defg-hij\) | @@ -76,6 +79,9 @@ Get details of a Google Meet meeting space by name or meeting code | Parameter | Type | Description | | --------- | ---- | ----------- | +| `moderation` | string | Host moderation: ON or OFF | +| `attendanceReportGenerationType` | string | Attendance report: GENERATE_REPORT or DO_NOT_GENERATE | +| `artifactConfig` | json | Auto-generated artifacts: recordingConfig.autoRecordingGeneration, transcriptionConfig.autoTranscriptionGeneration, smartNotesConfig.autoSmartNotesGeneration \(ON or OFF\) | | `name` | string | Resource name of the space | | `meetingUri` | string | Meeting URL | | `meetingCode` | string | Meeting code | @@ -171,7 +177,7 @@ List participants of a conference record | Parameter | Type | Description | | --------- | ---- | ----------- | -| `participants` | json | List of participants with name, times, display name, and user type | +| `participants` | json | List of participants \[\{ name, earliestStartTime, latestEndTime, displayName, userType \(signed_in, anonymous, phone\), user \(users/\{userId\} for signed-in participants, otherwise null\) \}\] | | `nextPageToken` | string | Token for next page of results | | `totalSize` | number | Total number of participants | diff --git a/apps/docs/content/docs/integrations/google_sheets.mdx b/apps/docs/content/docs/integrations/google_sheets.mdx index 97fa3d0edce..a38a9c55487 100644 --- a/apps/docs/content/docs/integrations/google_sheets.mdx +++ b/apps/docs/content/docs/integrations/google_sheets.mdx @@ -50,6 +50,9 @@ Read data from a specific sheet in a Google Sheets spreadsheet | `spreadsheetId` | string | Yes | Google Sheets spreadsheet ID | | `sheetName` | string | Yes | The name of the sheet/tab to read from | | `cellRange` | string | No | The cell range to read \(e.g. "A1:D10"\). Defaults to "A1:Z1000" if not specified. | +| `majorDimension` | string | No | The major dimension of the returned values: "ROWS" \(default\) or "COLUMNS" | +| `valueRenderOption` | string | No | How values should be rendered: "FORMATTED_VALUE" \(default, as displayed\), "UNFORMATTED_VALUE" \(raw numbers/booleans\), or "FORMULA" \(formulas instead of results\) | +| `dateTimeRenderOption` | string | No | How dates, times, and durations should be rendered: "SERIAL_NUMBER" \(default, days since 1899-12-30\) or "FORMATTED_STRING" \(as displayed\). Ignored when the value render option is "FORMATTED_VALUE". | | `filterColumn` | string | No | Column name \(from the header row\) to filter on. Filtering is applied to the rows returned by the read range \(the default is A1:Z1000\), not the entire sheet. If not provided, no filtering is applied. | | `filterValue` | string | No | Value to match against the filter column. | | `filterMatchType` | string | No | How to match the filter value. Text: "contains", "not_contains", "exact", "not_equals", "starts_with", "ends_with". Numeric/ordering: "gt", "gte", "lt", "lte" \(numeric when both values are numbers, otherwise lexicographic\). Defaults to "contains". | @@ -61,6 +64,7 @@ Read data from a specific sheet in a Google Sheets spreadsheet | `sheetName` | string | Name of the sheet that was read | | `range` | string | The range of cells that was read | | `values` | array | The cell values as a 2D array | +| `majorDimension` | string | Major dimension of the returned values \(ROWS or COLUMNS\) | | `metadata` | json | Spreadsheet metadata including ID and URL | | ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID | | ↳ `spreadsheetUrl` | string | Spreadsheet URL | @@ -82,6 +86,9 @@ Write data to a specific sheet in a Google Sheets spreadsheet | `cellRange` | string | No | The cell range to write to \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. | | `values` | array | Yes | The data to write as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\], \["Bob", 25\]\]\) or array of objects. | | `valueInputOption` | string | No | The format of the data to write | +| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response | +| `responseValueRenderOption` | string | No | How values in updatedData should be rendered \(only with includeValuesInResponse\): "FORMATTED_VALUE" \(default, as displayed\), "UNFORMATTED_VALUE" \(raw numbers/booleans\), or "FORMULA" \(formulas instead of results\) | +| `responseDateTimeRenderOption` | string | No | How dates in updatedData should be rendered \(only with includeValuesInResponse\): "SERIAL_NUMBER" \(default, days since 1899-12-30\) or "FORMATTED_STRING" \(as displayed\). Ignored when the value render option is "FORMATTED_VALUE". | #### Output @@ -112,6 +119,9 @@ Update data in a specific sheet in a Google Sheets spreadsheet | `cellRange` | string | No | The cell range to update \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. | | `values` | array | Yes | The data to update as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\]\]\) or array of objects. | | `valueInputOption` | string | No | The format of the data to update | +| `includeValuesInResponse` | boolean | No | Whether to include the updated values in the response | +| `responseValueRenderOption` | string | No | How values in updatedData should be rendered \(only with includeValuesInResponse\): "FORMATTED_VALUE" \(default, as displayed\), "UNFORMATTED_VALUE" \(raw numbers/booleans\), or "FORMULA" \(formulas instead of results\) | +| `responseDateTimeRenderOption` | string | No | How dates in updatedData should be rendered \(only with includeValuesInResponse\): "SERIAL_NUMBER" \(default, days since 1899-12-30\) or "FORMATTED_STRING" \(as displayed\). Ignored when the value render option is "FORMATTED_VALUE". | #### Output @@ -142,6 +152,9 @@ Append data to the end of a specific sheet in a Google Sheets spreadsheet | `values` | array | Yes | The data to append as a 2D array \(e.g. \[\["Alice", 30\], \["Bob", 25\]\]\) or array of objects. | | `valueInputOption` | string | No | The format of the data to append | | `insertDataOption` | string | No | How to insert the data \(OVERWRITE or INSERT_ROWS\) | +| `includeValuesInResponse` | boolean | No | Whether to include the appended values in the response | +| `responseValueRenderOption` | string | No | How values in updatedData should be rendered \(only with includeValuesInResponse\): "FORMATTED_VALUE" \(default, as displayed\), "UNFORMATTED_VALUE" \(raw numbers/booleans\), or "FORMULA" \(formulas instead of results\) | +| `responseDateTimeRenderOption` | string | No | How dates in updatedData should be rendered \(only with includeValuesInResponse\): "SERIAL_NUMBER" \(default, days since 1899-12-30\) or "FORMATTED_STRING" \(as displayed\). Ignored when the value render option is "FORMATTED_VALUE". | #### Output @@ -259,6 +272,7 @@ Read multiple ranges from a Google Sheets spreadsheet in a single request | `ranges` | json | Yes | Array of ranges to read \(e.g., \["Sheet1!A1:D10", "Sheet2!A1:B5"\]\). Each range should include sheet name. | | `majorDimension` | string | No | The major dimension of values: "ROWS" \(default\) or "COLUMNS" | | `valueRenderOption` | string | No | How values should be rendered: "FORMATTED_VALUE" \(default\), "UNFORMATTED_VALUE", or "FORMULA" | +| `dateTimeRenderOption` | string | No | How dates, times, and durations should be rendered: "SERIAL_NUMBER" \(default, days since 1899-12-30\) or "FORMATTED_STRING" \(as displayed\). Ignored when the value render option is "FORMATTED_VALUE". | #### Output @@ -286,8 +300,11 @@ Update multiple ranges in a Google Sheets spreadsheet in a single request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `spreadsheetId` | string | Yes | Google Sheets spreadsheet ID | -| `data` | json | Yes | Array of value ranges to update. Each item should have "range" \(e.g., "Sheet1!A1:D10"\) and "values" \(2D array\). | +| `data` | json | Yes | Array of value ranges to update. Each item should have "range" \(e.g., "Sheet1!A1:D10"\), "values" \(2D array\), and optionally "majorDimension" \("ROWS" default, or "COLUMNS"\). | | `valueInputOption` | string | No | How input data should be interpreted: "RAW" or "USER_ENTERED" \(default\). USER_ENTERED parses formulas. | +| `includeValuesInResponse` | boolean | No | Whether each entry in responses should include updatedData with the values of the updated cells. Defaults to false. | +| `responseValueRenderOption` | string | No | How values in updatedData should be rendered \(only with includeValuesInResponse\): "FORMATTED_VALUE" \(default, as displayed\), "UNFORMATTED_VALUE" \(raw numbers/booleans\), or "FORMULA" \(formulas instead of results\) | +| `responseDateTimeRenderOption` | string | No | How dates in updatedData should be rendered \(only with includeValuesInResponse\): "SERIAL_NUMBER" \(default, days since 1899-12-30\) or "FORMATTED_STRING" \(as displayed\). Ignored when the value render option is "FORMATTED_VALUE". | #### Output diff --git a/apps/docs/content/docs/integrations/google_slides.mdx b/apps/docs/content/docs/integrations/google_slides.mdx index 5c5aa00df9b..511cc42560f 100644 --- a/apps/docs/content/docs/integrations/google_slides.mdx +++ b/apps/docs/content/docs/integrations/google_slides.mdx @@ -52,6 +52,12 @@ Read content from a Google Slides presentation | Parameter | Type | Description | | --------- | ---- | ----------- | | `slides` | json | Array of slides with their content | +| `slideContents` | array | Plain-text view of each slide in order, including speaker notes | +| ↳ `objectId` | string | Slide object ID | +| ↳ `index` | number | Zero-based slide position | +| ↳ `text` | string | Text from the slide shapes, tables, and groups, one block per line | +| ↳ `speakerNotes` | string | Speaker notes text \(empty if none\) | +| ↳ `isSkipped` | boolean | Whether the slide is skipped in presentation mode | | `metadata` | json | Presentation metadata including ID, title, and URL | | ↳ `presentationId` | string | The presentation ID | | ↳ `title` | string | The presentation title | @@ -60,6 +66,8 @@ Read content from a Google Slides presentation | ↳ `height` | json | Page height as a Dimension object | | ↳ `mimeType` | string | The mime type of the presentation | | ↳ `url` | string | URL to open the presentation | +| ↳ `revisionId` | string | Revision ID of the presentation \(only for users with edit access\); pass as requiredRevisionId in a batch update writeControl | +| ↳ `locale` | string | Presentation locale as an IETF BCP 47 language tag | @@ -259,6 +267,14 @@ Get detailed information about a specific slide/page in a Google Slides presenta | ↳ `layoutObjectId` | string | Object ID of the layout this slide is based on | | ↳ `masterObjectId` | string | Object ID of the master this slide is based on | | ↳ `notesPage` | json | The notes page associated with the slide | +| ↳ `isSkipped` | boolean | Whether the slide is skipped in presentation mode | +| `revisionId` | string | Revision ID of the presentation; pass as requiredRevisionId in a batch update writeControl | +| `pageProperties` | json | Page properties \(background fill, color scheme\) | +| `layoutProperties` | json | Layout properties \(masterObjectId, name, displayName\); set only for LAYOUT pages | +| `notesProperties` | json | Notes properties \(speakerNotesObjectId\); set only for NOTES pages | +| `masterProperties` | json | Master properties \(displayName\); set only for MASTER pages | +| `pageText` | string | Text from the page shapes, tables, and groups, one block per line | +| `speakerNotes` | string | Speaker notes text of the slide \(empty if none\) | | `metadata` | object | Operation metadata including presentation ID and URL | | ↳ `presentationId` | string | The presentation ID | | ↳ `url` | string | URL to the presentation | diff --git a/apps/docs/content/docs/integrations/google_tasks.mdx b/apps/docs/content/docs/integrations/google_tasks.mdx index 85f987a6465..be354522021 100644 --- a/apps/docs/content/docs/integrations/google_tasks.mdx +++ b/apps/docs/content/docs/integrations/google_tasks.mdx @@ -47,7 +47,7 @@ Create a new task in a Google Tasks list | `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) | | `title` | string | Yes | Title of the task \(max 1024 characters\) | | `notes` | string | No | Notes/description for the task \(max 8192 characters\) | -| `due` | string | No | Due date in RFC 3339 format \(e.g., 2025-06-03T00:00:00.000Z\) | +| `due` | string | No | Due date in RFC 3339 format \(e.g., 2025-06-03T00:00:00.000Z\). Google Tasks stores only the date; the time is discarded | | `status` | string | No | Task status: "needsAction" or "completed" | | `parent` | string | No | Parent task ID to create this task as a subtask. Omit for top-level tasks. | | `previous` | string | No | Previous sibling task ID to position after. Omit to place first among siblings. | @@ -56,6 +56,11 @@ Create a new task in a Google Tasks list | Parameter | Type | Description | | --------- | ---- | ----------- | +| `links` | array | Read-only links attached to the task | +| ↳ `type` | string | Link type \(e.g., "email", "generic", "chat_message", "keep_note"\) | +| ↳ `description` | string | Link description | +| ↳ `link` | string | The URL | +| `assignmentInfo` | json | Source of a task assigned to the current user from Google Docs or Chat \(linkToTask, surfaceType, driveResourceInfo, spaceInfo\) | | `id` | string | Task ID | | `title` | string | Task title | | `notes` | string | Task notes | @@ -68,6 +73,7 @@ Create a new task in a Google Tasks list | `position` | string | Position among sibling tasks | | `completed` | string | Completion date | | `deleted` | boolean | Whether the task is deleted | +| `hidden` | boolean | Whether the task was hidden when its list was cleared | @@ -84,7 +90,7 @@ List all tasks in a Google Tasks list | `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) | | `maxResults` | number | No | Maximum number of tasks to return \(default 20, max 100\) | | `pageToken` | string | No | Token for pagination | -| `showCompleted` | boolean | No | Whether to show completed tasks \(default true\) | +| `showCompleted` | boolean | No | Whether to show completed tasks \(default true\). Tasks completed in the Google Tasks apps are also hidden, so set showHidden to true to include them | | `showDeleted` | boolean | No | Whether to show deleted tasks \(default false\) | | `showHidden` | boolean | No | Whether to show hidden tasks \(default false\) | | `dueMin` | string | No | Lower bound for due date filter \(RFC 3339 timestamp\) | @@ -92,12 +98,18 @@ List all tasks in a Google Tasks list | `completedMin` | string | No | Lower bound for task completion date \(RFC 3339 timestamp\) | | `completedMax` | string | No | Upper bound for task completion date \(RFC 3339 timestamp\) | | `updatedMin` | string | No | Lower bound for last modification time \(RFC 3339 timestamp\) | +| `showAssigned` | boolean | No | Whether to include tasks assigned to the current user from Google Docs or Chat \(default false\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `tasks` | array | List of tasks | +| ↳ `links` | array | Read-only links attached to the task | +| ↳ `type` | string | Link type \(e.g., "email", "generic", "chat_message", "keep_note"\) | +| ↳ `description` | string | Link description | +| ↳ `link` | string | The URL | +| ↳ `assignmentInfo` | json | Source of a task assigned to the current user from Google Docs or Chat \(linkToTask, surfaceType, driveResourceInfo, spaceInfo\) | | ↳ `id` | string | Task identifier | | ↳ `title` | string | Title of the task | | ↳ `notes` | string | Notes/description for the task | @@ -111,10 +123,6 @@ List all tasks in a Google Tasks list | ↳ `position` | string | Position among sibling tasks \(string-based ordering\) | | ↳ `hidden` | boolean | Whether the task is hidden | | ↳ `deleted` | boolean | Whether the task is deleted | -| ↳ `links` | array | Collection of links associated with the task | -| ↳ `type` | string | Link type \(e.g., "email", "generic", "chat_message"\) | -| ↳ `description` | string | Link description | -| ↳ `link` | string | The URL | | `nextPageToken` | string | Token for retrieving the next page of results | @@ -136,6 +144,11 @@ Retrieve a specific task by ID from a Google Tasks list | Parameter | Type | Description | | --------- | ---- | ----------- | +| `links` | array | Read-only links attached to the task | +| ↳ `type` | string | Link type \(e.g., "email", "generic", "chat_message", "keep_note"\) | +| ↳ `description` | string | Link description | +| ↳ `link` | string | The URL | +| `assignmentInfo` | json | Source of a task assigned to the current user from Google Docs or Chat \(linkToTask, surfaceType, driveResourceInfo, spaceInfo\) | | `id` | string | Task ID | | `title` | string | Task title | | `notes` | string | Task notes | @@ -148,6 +161,7 @@ Retrieve a specific task by ID from a Google Tasks list | `position` | string | Position among sibling tasks | | `completed` | string | Completion date | | `deleted` | boolean | Whether the task is deleted | +| `hidden` | boolean | Whether the task was hidden when its list was cleared | @@ -165,13 +179,18 @@ Update an existing task in a Google Tasks list | `taskId` | string | Yes | The ID of the task to update | | `title` | string | No | New title for the task | | `notes` | string | No | New notes for the task | -| `due` | string | No | New due date in RFC 3339 format | +| `due` | string | No | New due date in RFC 3339 format. Google Tasks stores only the date; the time is discarded | | `status` | string | No | New status: "needsAction" or "completed" | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | +| `links` | array | Read-only links attached to the task | +| ↳ `type` | string | Link type \(e.g., "email", "generic", "chat_message", "keep_note"\) | +| ↳ `description` | string | Link description | +| ↳ `link` | string | The URL | +| `assignmentInfo` | json | Source of a task assigned to the current user from Google Docs or Chat \(linkToTask, surfaceType, driveResourceInfo, spaceInfo\) | | `id` | string | Task ID | | `title` | string | Task title | | `notes` | string | Task notes | @@ -184,6 +203,7 @@ Update an existing task in a Google Tasks list | `position` | string | Position among sibling tasks | | `completed` | string | Completion date | | `deleted` | boolean | Whether the task is deleted | +| `hidden` | boolean | Whether the task was hidden when its list was cleared | @@ -219,7 +239,7 @@ Retrieve all task lists for the authenticated user | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `maxResults` | number | No | Maximum number of task lists to return \(default 20, max 100\) | +| `maxResults` | number | No | Maximum number of task lists to return \(default 1000, max 1000\) | | `pageToken` | string | No | Token for pagination | #### Output diff --git a/apps/docs/content/docs/integrations/google_vault.mdx b/apps/docs/content/docs/integrations/google_vault.mdx index 34c1842c41b..2d0280175ca 100644 --- a/apps/docs/content/docs/integrations/google_vault.mdx +++ b/apps/docs/content/docs/integrations/google_vault.mdx @@ -54,12 +54,15 @@ Create an export in a matter | `startTime` | string | No | Start time for date filtering \(ISO 8601 format, e.g., "2024-01-01T00:00:00Z"\) | | `endTime` | string | No | End time for date filtering \(ISO 8601 format, e.g., "2024-12-31T23:59:59Z"\) | | `terms` | string | No | Search query terms to filter exported content \(e.g., "from:sender@example.com subject:invoice"\) | +| `includeSharedDrives` | boolean | No | Set to true to include files in shared drives \(DRIVE corpus only\) | +| `exportFormat` | string | No | File format for exported messages: MBOX or PST \(MAIL, GROUPS, HANGOUTS_CHAT, and VOICE corpus; ignored for DRIVE\) | +| `dataScope` | string | No | Data to search: ALL_DATA \(default\), HELD_DATA \(only data on hold\), or UNPROCESSED_DATA \(MAIL and GROUPS only\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `export` | json | Created export object | +| `export` | json | Created export object \(id, matterId, name, status, createTime, query, exportOptions, requester, stats, cloudStorageSink once completed\) | @@ -75,15 +78,15 @@ List exports for a matter | --------- | ---- | -------- | ----------- | | `matterId` | string | Yes | The matter ID \(e.g., "12345678901234567890"\) | | `pageSize` | number | No | Number of exports to return per page | -| `pageToken` | string | No | Token for pagination | +| `pageToken` | string | No | Token for pagination \(nextPageToken from a previous response\) | | `exportId` | string | No | Optional export ID to fetch a specific export \(e.g., "exportId123456"\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `exports` | json | Array of export objects | -| `export` | json | Single export object \(when exportId is provided\) | +| `exports` | json | Array of export objects \(id, name, status, createTime, query, exportOptions, requester, stats, cloudStorageSink.files with bucketName/objectName/size/md5Hash\) | +| `export` | json | Single export object \(when exportId is provided\), with the same fields as each exports entry | | `nextPageToken` | string | Token for fetching next page of results | @@ -172,14 +175,14 @@ List holds for a matter | --------- | ---- | -------- | ----------- | | `matterId` | string | Yes | The matter ID \(e.g., "12345678901234567890"\) | | `pageSize` | number | No | Number of holds to return per page | -| `pageToken` | string | No | Token for pagination | +| `pageToken` | string | No | Token for pagination \(nextPageToken from a previous response\) | | `holdId` | string | No | Optional hold ID to fetch a specific hold \(e.g., "holdId123456"\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `holds` | json | Array of hold objects | +| `holds` | json | Array of hold objects \(holdId, name, corpus, updateTime, query, accounts with accountId/email/firstName/lastName/holdTime, or orgUnit\) | | `hold` | json | Single hold object \(when holdId is provided\) | | `nextPageToken` | string | Token for fetching next page of results | @@ -313,15 +316,17 @@ List matters, or get a specific matter if matterId is provided | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `pageSize` | number | No | Number of matters to return per page | -| `pageToken` | string | No | Token for pagination | +| `pageToken` | string | No | Token for pagination \(nextPageToken from a previous response\) | | `matterId` | string | No | Optional matter ID to fetch a specific matter \(e.g., "12345678901234567890"\) | +| `view` | string | No | Amount of detail to return: BASIC \(ID, name, description, state; the default\) or FULL \(also includes matterPermissions, the owners and collaborators\) | +| `state` | string | No | Only list matters in this state: OPEN, CLOSED, or DELETED \(default lists all states; ignored when matterId is provided\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `matters` | json | Array of matter objects | -| `matter` | json | Single matter object \(when matterId is provided\) | +| `matters` | json | Array of matter objects \(matterId, name, description, state; matterPermissions when view is FULL\) | +| `matter` | json | Single matter object \(when matterId is provided\): matterId, name, description, state; matterPermissions when view is FULL | | `nextPageToken` | string | Token for fetching next page of results | @@ -491,6 +496,8 @@ Save a reusable search query in a matter | `startTime` | string | No | Start time for date filtering \(ISO 8601 format, e.g., "2024-01-01T00:00:00Z"\) | | `endTime` | string | No | End time for date filtering \(ISO 8601 format, e.g., "2024-12-31T23:59:59Z"\) | | `terms` | string | No | Search query terms \(e.g., "from:sender@example.com subject:invoice"\) | +| `includeSharedDrives` | boolean | No | Set to true to include files in shared drives \(DRIVE corpus only\) | +| `dataScope` | string | No | Data to search: ALL_DATA \(default\), HELD_DATA \(only data on hold\), or UNPROCESSED_DATA \(MAIL and GROUPS only\) | #### Output diff --git a/apps/docs/content/docs/integrations/iam.mdx b/apps/docs/content/docs/integrations/iam.mdx index 68ed7f380d5..f4f8f6b7e3f 100644 --- a/apps/docs/content/docs/integrations/iam.mdx +++ b/apps/docs/content/docs/integrations/iam.mdx @@ -90,6 +90,8 @@ Create a new IAM user | `region` | string | Yes | AWS region \(e.g., us-east-1\) | | `userName` | string | Yes | Name for the new IAM user \(1-64 characters\) | | `path` | string | No | Path for the user \(e.g., /division_abc/\), defaults to / | +| `permissionsBoundary` | string | No | ARN of a managed policy to set as the user permissions boundary | +| `tags` | string | No | JSON object of up to 50 tag key/value pairs to attach to the new user | #### Output @@ -101,6 +103,8 @@ Create a new IAM user | `arn` | string | The ARN of the created user | | `path` | string | The path of the created user | | `createDate` | string | Date the user was created | +| `permissionsBoundaryArn` | string | ARN of the permissions boundary policy set on the user | +| `tags` | json | Tags attached to the user \(key, value pairs\) | @@ -178,6 +182,8 @@ Get detailed information about an IAM role | `assumeRolePolicyDocument` | string | The trust policy document \(JSON\) | | `roleLastUsedDate` | string | Date the role was last used | | `roleLastUsedRegion` | string | AWS region where the role was last used | +| `permissionsBoundaryArn` | string | ARN of the permissions boundary policy set on the role | +| `tags` | json | Tags attached to the role \(key, value pairs\) | @@ -197,6 +203,8 @@ Create a new IAM role with a trust policy | `description` | string | No | Description of the role | | `path` | string | No | Path for the role \(e.g., /application/\), defaults to / | | `maxSessionDuration` | number | No | Maximum session duration in seconds \(3600-43200, default 3600\) | +| `permissionsBoundary` | string | No | ARN of a managed policy to set as the role permissions boundary | +| `tags` | string | No | JSON object of up to 50 tag key/value pairs to attach to the new role | #### Output @@ -208,6 +216,8 @@ Create a new IAM role with a trust policy | `arn` | string | The ARN of the created role | | `path` | string | The path of the created role | | `createDate` | string | Date the role was created | +| `permissionsBoundaryArn` | string | ARN of the permissions boundary policy set on the role | +| `tags` | json | Tags attached to the role \(key, value pairs\) | @@ -336,6 +346,7 @@ List managed IAM policies | `pathPrefix` | string | No | Path prefix to filter policies | | `maxItems` | number | No | Maximum number of policies to return \(1-1000, default 100\) | | `marker` | string | No | Pagination marker from a previous request | +| `policyUsageFilter` | string | No | Filter by how the policy is used. Must be exactly one of: PermissionsPolicy, PermissionsBoundary | #### Output diff --git a/apps/docs/content/docs/integrations/identity_center.mdx b/apps/docs/content/docs/integrations/identity_center.mdx index 5655532ee83..2287f68e7ba 100644 --- a/apps/docs/content/docs/integrations/identity_center.mdx +++ b/apps/docs/content/docs/integrations/identity_center.mdx @@ -164,6 +164,13 @@ Look up a user in the Identity Store by email address | `userName` | string | Username in the Identity Store | | `displayName` | string | Display name of the user | | `email` | string | Email address of the user | +| `givenName` | string | Given \(first\) name, or null when the Identity Store omits it | +| `familyName` | string | Family \(last\) name, or null when the Identity Store omits it | +| `userStatus` | string | Account status \(ENABLED or DISABLED\) | +| `title` | string | Job title of the user | +| `externalIds` | array | External identity provider IDs linked to the user | +| ↳ `issuer` | string | Identity provider that issued the ID | +| ↳ `id` | string | Identifier at the issuer | @@ -191,6 +198,8 @@ Resolve an Identity Store user ID to the user behind it. Use to turn the princip | `email` | string | Primary email address, or null when the user has no email attribute | | `userStatus` | string | Account status \(ENABLED or DISABLED\), or null when the Identity Store omits it | | `title` | string | Job title, or null when the Identity Store omits it | +| `givenName` | string | Given \(first\) name, or null when the Identity Store omits it | +| `familyName` | string | Family \(last\) name, or null when the Identity Store omits it | | `externalIds` | array | External identity provider IDs linked to the user | | ↳ `issuer` | string | Identity provider that issued the ID | | ↳ `id` | string | Identifier at the issuer | @@ -218,6 +227,9 @@ Look up a group in the Identity Store by display name | `groupId` | string | Identity Store group ID \(use as principalId\) | | `displayName` | string | Display name of the group | | `description` | string | Group description | +| `externalIds` | array | External identity provider IDs linked to the group | +| ↳ `issuer` | string | Identity provider that issued the ID | +| ↳ `id` | string | Identifier at the issuer | @@ -450,6 +462,7 @@ List every account and permission set a specific user or group is assigned. Use | `principalType` | string | Yes | Type of principal: USER or GROUP | | `maxResults` | number | No | Maximum number of assignments to return \(1-100\) | | `nextToken` | string | No | Pagination token from a previous request | +| `accountId` | string | No | Only return assignments in this 12-digit AWS account ID | #### Output diff --git a/apps/docs/content/docs/integrations/infisical.mdx b/apps/docs/content/docs/integrations/infisical.mdx index 2142981828f..b0f69da2acc 100644 --- a/apps/docs/content/docs/integrations/infisical.mdx +++ b/apps/docs/content/docs/integrations/infisical.mdx @@ -10,9 +10,11 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} -Use [Infisical](https://infisical.com/) to list, read, create, update, or delete secrets in a project environment. Connect a saved Infisical credential (a machine identity access token), then set the project ID, environment slug, and any secret path required by the selected operation. +Use [Infisical](https://infisical.com/) to list, read, create, update, or delete secrets in a project environment. Connect a saved Infisical credential (a machine identity's Universal Auth client ID and secret, or its access token), then set the project ID, environment slug, and any secret path required by the selected operation. -Sim stores the access token as pasted and does not renew it, so the credential stops working when the token's Access Token TTL runs out. On the identity's Universal Auth or Token Auth configuration, set a long Access Token TTL and Access Token Max TTL, and leave Access Token Period unset: a periodic token expires at the end of each period unless it is renewed. Replace the credential with a new token before the old one expires. See Infisical's [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) and [Token Auth](https://infisical.com/docs/documentation/platform/identities/token-auth) guides. +The recommended setup is **Universal Auth (client ID and secret)**: in Infisical, add Universal Auth to the machine identity and create a Client Secret; in Sim, add the Infisical credential, choose that authentication, and enter the Client ID, Client Secret, and instance URL (`https://us.infisical.com` for US Cloud, `https://eu.infisical.com` for EU Cloud, or your self-hosted URL). Sim logs in with them whenever its access token expires, so the credential keeps working without a renewed token. Each login spends one use of the Client Secret, so leave its usage limit at `0` (unlimited) and leave Access Token Max Number of Uses at `0`; if the identity restricts trusted IPs, allow Sim's outbound addresses. See Infisical's [Universal Auth guide](https://infisical.com/docs/documentation/platform/identities/universal-auth). + +You can instead paste a machine identity **access token**. Sim stores it as pasted and does not renew it, so the credential stops working when the token's Access Token TTL runs out. On the identity's Universal Auth or Token Auth configuration, set a long Access Token TTL and Access Token Max TTL, and leave Access Token Period unset: a periodic token expires at the end of each period unless it is renewed. Replace the credential with a new token before the old one expires. See Infisical's [Token Auth](https://infisical.com/docs/documentation/platform/identities/token-auth) guide. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/docs/content/docs/integrations/intune.mdx b/apps/docs/content/docs/integrations/intune.mdx index fded003b153..cff989779ca 100644 --- a/apps/docs/content/docs/integrations/intune.mdx +++ b/apps/docs/content/docs/integrations/intune.mdx @@ -67,7 +67,7 @@ Read one page of devices from Microsoft Intune | ↳ `id` | string | Intune managed device ID | | ↳ `deviceName` | string | Device name | | ↳ `managedDeviceOwnerType` | string | Ownership: company, personal, or unknown | -| ↳ `managementState` | string | Current device management state | +| ↳ `managementState` | string | Device management state; not part of the Graph v1.0 managedDevice resource, so usually null | | ↳ `enrolledDateTime` | string | Enrollment time | | ↳ `lastSyncDateTime` | string | Last check-in time | | ↳ `operatingSystem` | string | Operating system | @@ -84,6 +84,33 @@ Read one page of devices from Microsoft Intune | ↳ `model` | string | Device model | | ↳ `totalStorageSpaceInBytes` | number | Total storage capacity in bytes | | ↳ `freeStorageSpaceInBytes` | number | Available storage in bytes | +| ↳ `managedDeviceName` | string | Intune-generated or administrator-assigned device name | +| ↳ `managementAgent` | string | Management channel, such as mdm, eas, easMdm, or configurationManagerClient | +| ↳ `deviceEnrollmentType` | string | Enrollment type, such as userEnrollment or windowsAzureADJoin | +| ↳ `enrollmentProfileName` | string | Assigned enrollment profile name; empty when none was assigned | +| ↳ `deviceRegistrationState` | string | Registration state, such as registered, notRegistered, or revoked | +| ↳ `deviceCategoryDisplayName` | string | Device category display name | +| ↳ `jailBroken` | string | Whether the device is jailbroken or rooted, as reported by Intune | +| ↳ `isSupervised` | boolean | Device supervised status | +| ↳ `azureADRegistered` | boolean | Whether the device is Microsoft Entra registered | +| ↳ `complianceGracePeriodExpirationDateTime` | string | When the device compliance grace period expires | +| ↳ `managementCertificateExpirationDate` | string | Device management certificate expiration date | +| ↳ `partnerReportedThreatState` | string | Mobile Threat Defense partner threat state, such as secured or highSeverity | +| ↳ `exchangeAccessState` | string | Exchange access state: none, unknown, allowed, blocked, or quarantined | +| ↳ `exchangeAccessStateReason` | string | Reason for the Exchange access state | +| ↳ `exchangeLastSuccessfulSyncDateTime` | string | Last time the device contacted Exchange | +| ↳ `easActivated` | boolean | Whether Exchange ActiveSync is activated | +| ↳ `androidSecurityPatchLevel` | string | Android security patch level | +| ↳ `imei` | string | Device IMEI | +| ↳ `meid` | string | Device MEID | +| ↳ `phoneNumber` | string | Device phone number | +| ↳ `subscriberCarrier` | string | Subscriber carrier | +| ↳ `wiFiMacAddress` | string | Wi-Fi MAC address | +| ↳ `deviceActionResults` | array | Status of remote actions requested on the device, such as rebootNow or retire | +| ↳ `actionName` | string | Action name | +| ↳ `actionState` | string | Action state, such as pending, done, failed, or notSupported | +| ↳ `startDateTime` | string | Action start time | +| ↳ `lastUpdatedDateTime` | string | Last action state update time | @@ -107,7 +134,7 @@ Read device details from Microsoft Intune | ↳ `id` | string | Intune managed device ID | | ↳ `deviceName` | string | Device name | | ↳ `managedDeviceOwnerType` | string | Ownership: company, personal, or unknown | -| ↳ `managementState` | string | Current device management state | +| ↳ `managementState` | string | Device management state; not part of the Graph v1.0 managedDevice resource, so usually null | | ↳ `enrolledDateTime` | string | Enrollment time | | ↳ `lastSyncDateTime` | string | Last check-in time | | ↳ `operatingSystem` | string | Operating system | @@ -124,6 +151,33 @@ Read device details from Microsoft Intune | ↳ `model` | string | Device model | | ↳ `totalStorageSpaceInBytes` | number | Total storage capacity in bytes | | ↳ `freeStorageSpaceInBytes` | number | Available storage in bytes | +| ↳ `managedDeviceName` | string | Intune-generated or administrator-assigned device name | +| ↳ `managementAgent` | string | Management channel, such as mdm, eas, easMdm, or configurationManagerClient | +| ↳ `deviceEnrollmentType` | string | Enrollment type, such as userEnrollment or windowsAzureADJoin | +| ↳ `enrollmentProfileName` | string | Assigned enrollment profile name; empty when none was assigned | +| ↳ `deviceRegistrationState` | string | Registration state, such as registered, notRegistered, or revoked | +| ↳ `deviceCategoryDisplayName` | string | Device category display name | +| ↳ `jailBroken` | string | Whether the device is jailbroken or rooted, as reported by Intune | +| ↳ `isSupervised` | boolean | Device supervised status | +| ↳ `azureADRegistered` | boolean | Whether the device is Microsoft Entra registered | +| ↳ `complianceGracePeriodExpirationDateTime` | string | When the device compliance grace period expires | +| ↳ `managementCertificateExpirationDate` | string | Device management certificate expiration date | +| ↳ `partnerReportedThreatState` | string | Mobile Threat Defense partner threat state, such as secured or highSeverity | +| ↳ `exchangeAccessState` | string | Exchange access state: none, unknown, allowed, blocked, or quarantined | +| ↳ `exchangeAccessStateReason` | string | Reason for the Exchange access state | +| ↳ `exchangeLastSuccessfulSyncDateTime` | string | Last time the device contacted Exchange | +| ↳ `easActivated` | boolean | Whether Exchange ActiveSync is activated | +| ↳ `androidSecurityPatchLevel` | string | Android security patch level | +| ↳ `imei` | string | Device IMEI | +| ↳ `meid` | string | Device MEID | +| ↳ `phoneNumber` | string | Device phone number | +| ↳ `subscriberCarrier` | string | Subscriber carrier | +| ↳ `wiFiMacAddress` | string | Wi-Fi MAC address | +| ↳ `deviceActionResults` | array | Status of remote actions requested on the device, such as rebootNow or retire | +| ↳ `actionName` | string | Action name | +| ↳ `actionState` | string | Action state, such as pending, done, failed, or notSupported | +| ↳ `startDateTime` | string | Action start time | +| ↳ `lastUpdatedDateTime` | string | Last action state update time | @@ -206,7 +260,7 @@ Read one page of managed devices that have a detected application installed | ↳ `id` | string | Intune managed device ID | | ↳ `deviceName` | string | Device name | | ↳ `managedDeviceOwnerType` | string | Ownership: company, personal, or unknown | -| ↳ `managementState` | string | Current device management state | +| ↳ `managementState` | string | Device management state; not part of the Graph v1.0 managedDevice resource, so usually null | | ↳ `enrolledDateTime` | string | Enrollment time | | ↳ `lastSyncDateTime` | string | Last check-in time | | ↳ `operatingSystem` | string | Operating system | @@ -223,6 +277,33 @@ Read one page of managed devices that have a detected application installed | ↳ `model` | string | Device model | | ↳ `totalStorageSpaceInBytes` | number | Total storage capacity in bytes | | ↳ `freeStorageSpaceInBytes` | number | Available storage in bytes | +| ↳ `managedDeviceName` | string | Intune-generated or administrator-assigned device name | +| ↳ `managementAgent` | string | Management channel, such as mdm, eas, easMdm, or configurationManagerClient | +| ↳ `deviceEnrollmentType` | string | Enrollment type, such as userEnrollment or windowsAzureADJoin | +| ↳ `enrollmentProfileName` | string | Assigned enrollment profile name; empty when none was assigned | +| ↳ `deviceRegistrationState` | string | Registration state, such as registered, notRegistered, or revoked | +| ↳ `deviceCategoryDisplayName` | string | Device category display name | +| ↳ `jailBroken` | string | Whether the device is jailbroken or rooted, as reported by Intune | +| ↳ `isSupervised` | boolean | Device supervised status | +| ↳ `azureADRegistered` | boolean | Whether the device is Microsoft Entra registered | +| ↳ `complianceGracePeriodExpirationDateTime` | string | When the device compliance grace period expires | +| ↳ `managementCertificateExpirationDate` | string | Device management certificate expiration date | +| ↳ `partnerReportedThreatState` | string | Mobile Threat Defense partner threat state, such as secured or highSeverity | +| ↳ `exchangeAccessState` | string | Exchange access state: none, unknown, allowed, blocked, or quarantined | +| ↳ `exchangeAccessStateReason` | string | Reason for the Exchange access state | +| ↳ `exchangeLastSuccessfulSyncDateTime` | string | Last time the device contacted Exchange | +| ↳ `easActivated` | boolean | Whether Exchange ActiveSync is activated | +| ↳ `androidSecurityPatchLevel` | string | Android security patch level | +| ↳ `imei` | string | Device IMEI | +| ↳ `meid` | string | Device MEID | +| ↳ `phoneNumber` | string | Device phone number | +| ↳ `subscriberCarrier` | string | Subscriber carrier | +| ↳ `wiFiMacAddress` | string | Wi-Fi MAC address | +| ↳ `deviceActionResults` | array | Status of remote actions requested on the device, such as rebootNow or retire | +| ↳ `actionName` | string | Action name | +| ↳ `actionState` | string | Action state, such as pending, done, failed, or notSupported | +| ↳ `startDateTime` | string | Action start time | +| ↳ `lastUpdatedDateTime` | string | Last action state update time | @@ -251,6 +332,7 @@ Read one page of compliance policies from Microsoft Intune | ↳ `createdDateTime` | string | Creation time | | ↳ `lastModifiedDateTime` | string | Last modification time | | ↳ `version` | number | Policy or configuration version | +| ↳ `odataType` | string | Graph @odata.type naming the platform-specific policy type, such as #microsoft.graph.windows10CompliancePolicy | @@ -277,6 +359,7 @@ Read common metadata for an Intune compliance policy | ↳ `createdDateTime` | string | Creation time | | ↳ `lastModifiedDateTime` | string | Last modification time | | ↳ `version` | number | Policy or configuration version | +| ↳ `odataType` | string | Graph @odata.type naming the platform-specific policy type, such as #microsoft.graph.windows10CompliancePolicy | @@ -336,6 +419,7 @@ Read one page of device configurations from Microsoft Intune | ↳ `createdDateTime` | string | Creation time | | ↳ `lastModifiedDateTime` | string | Last modification time | | ↳ `version` | number | Policy or configuration version | +| ↳ `odataType` | string | Graph @odata.type naming the platform-specific policy type, such as #microsoft.graph.windows10CompliancePolicy | @@ -362,6 +446,7 @@ Read common metadata for an Intune device configuration | ↳ `createdDateTime` | string | Creation time | | ↳ `lastModifiedDateTime` | string | Last modification time | | ↳ `version` | number | Policy or configuration version | +| ↳ `odataType` | string | Graph @odata.type naming the platform-specific policy type, such as #microsoft.graph.windows10CompliancePolicy | diff --git a/apps/docs/content/docs/integrations/jira.mdx b/apps/docs/content/docs/integrations/jira.mdx index 851e72f4496..e1ec1e0a766 100644 --- a/apps/docs/content/docs/integrations/jira.mdx +++ b/apps/docs/content/docs/integrations/jira.mdx @@ -106,6 +106,9 @@ Retrieve detailed information about a specific Jira issue | `self` | string | REST API URL for this issue | | `summary` | string | Issue summary | | `description` | string | Issue description text \(extracted from ADF\) | +| `descriptionAdf` | json | Issue description as the raw Atlassian Document Format \(ADF\) document | +| `renderedDescription` | string | Issue description rendered as HTML \(requires the renderedFields expansion\) | +| `environment` | string | Environment text \(extracted from ADF\) | | `status` | object | Issue status | | ↳ `id` | string | Status ID | | ↳ `name` | string | Status name \(e.g., Open, In Progress, Done\) | @@ -274,6 +277,16 @@ Retrieve detailed information about a specific Jira issue | ↳ `timeZone` | string | User timezone | | ↳ `authorName` | string | Attachment author display name | | ↳ `created` | string | ISO 8601 timestamp when the attachment was created | +| `watchers` | array | Users watching the issue | +| ↳ `accountId` | string | Atlassian account ID of the user | +| ↳ `displayName` | string | Display name of the user | +| ↳ `active` | boolean | Whether the user account is active | +| ↳ `emailAddress` | string | Email address of the user | +| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) | +| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) | +| ↳ `timeZone` | string | User timezone | +| `customFields` | json | Every customfield_* value Jira returned, keyed by field ID \(e.g., sprint, story points, team\). Use the names output or Get Fields to map IDs to names. | +| `names` | json | Map of field ID to display name for every returned field | | `issueKey` | string | Issue key \(e.g., PROJ-123\) | | `issue` | json | Complete raw Jira issue object from the API | | `files` | file[] | Downloaded attachment files \(only when includeAttachments is true\) | @@ -303,6 +316,8 @@ Update a Jira issue | `environment` | string | No | Environment information for the issue | | `customFieldId` | string | No | Custom field ID to update \(e.g., customfield_10001\) | | `customFieldValue` | string | No | Value for the custom field | +| `customFields` | json | No | Additional fields as a JSON object mapping field IDs to Jira field values \(e.g., \{"customfield_10016": 5, "customfield_10020": 42, "customfield_10050": \{"value": "Option A"\}\}\). Numeric keys become customfield_<n>. Explicit params take precedence for the same field. | +| `parent` | json | No | New parent issue key or ID \(e.g., "PROJ-123" or \{ "key": "PROJ-123" \}\) to move a subtask or set the epic | | `notifyUsers` | boolean | No | Whether to send email notifications about this update \(default: true\) | #### Output @@ -342,6 +357,7 @@ Create a new Jira issue | `environment` | string | No | Environment information for the issue | | `customFieldId` | string | No | Custom field ID \(e.g., customfield_10001\) | | `customFieldValue` | string | No | Value for the custom field | +| `customFields` | json | No | Additional fields as a JSON object mapping field IDs to Jira field values \(e.g., \{"customfield_10016": 5, "customfield_10020": 42, "customfield_10050": \{"value": "Option A"\}\}\). Numeric keys become customfield_<n>. Explicit params take precedence for the same field. | #### Output @@ -501,6 +517,7 @@ Move a Jira issue between workflow statuses (e.g., To Do -> In Progress) | `transitionId` | string | Yes | ID of the transition to execute \(e.g., "11" for "To Do", "21" for "In Progress"\) | | `comment` | string | No | Optional comment to add when transitioning the issue | | `resolution` | string | No | Resolution name to set during transition \(e.g., "Fixed", "Won't Fix"\) | +| `fields` | json | No | Fields to set on the transition screen, as a JSON object mapping field IDs to Jira values \(e.g., \{"assignee": \{"accountId": "5b10..."\}, "customfield_10010": "value"\}\). The resolution param takes precedence over a resolution set here. | #### Output @@ -531,7 +548,8 @@ Search for Jira issues using JQL (Jira Query Language) | `jql` | string | Yes | JQL query string to search for issues \(e.g., "project = PROJ AND status = Open"\) | | `nextPageToken` | string | No | Cursor token for the next page of results. Omit for the first page. | | `maxResults` | number | No | Maximum number of results to return per page \(default: 50\) | -| `fields` | array | No | Array of field names to return \(default: all fields\). | +| `fields` | array | No | Array of field names to return \(default: all fields\). When set, each issue also includes descriptionAdf, customFields, attachments, issuelinks, and subtasks. | +| `expand` | string | No | Comma-separated expansions: renderedFields, names, schema, transitions, operations, editmeta, changelog | #### Output @@ -544,6 +562,9 @@ Search for Jira issues using JQL (Jira Query Language) | ↳ `self` | string | REST API URL for this issue | | ↳ `summary` | string | Issue summary | | ↳ `description` | string | Issue description text \(extracted from ADF\) | +| ↳ `descriptionAdf` | json | Issue description as the raw Atlassian Document Format \(ADF\) document. Only included when the fields param is set | +| ↳ `renderedDescription` | string | Issue description rendered as HTML. Only included when expand includes renderedFields | +| ↳ `environment` | string | Environment text \(extracted from ADF\) | | ↳ `status` | object | Issue status | | ↳ `id` | string | Status ID | | ↳ `name` | string | Status name \(e.g., Open, In Progress, Done\) | @@ -598,8 +619,81 @@ Search for Jira issues using JQL (Jira Query Language) | ↳ `duedate` | string | Due date \(YYYY-MM-DD\) | | ↳ `created` | string | ISO 8601 timestamp when the issue was created | | ↳ `updated` | string | ISO 8601 timestamp when the issue was last updated | +| ↳ `creator` | object | Issue creator | +| ↳ `accountId` | string | Atlassian account ID of the user | +| ↳ `displayName` | string | Display name of the user | +| ↳ `active` | boolean | Whether the user account is active | +| ↳ `emailAddress` | string | Email address of the user | +| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) | +| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) | +| ↳ `timeZone` | string | User timezone | +| ↳ `fixVersions` | array | Fix versions | +| ↳ `id` | string | Version ID | +| ↳ `name` | string | Version name | +| ↳ `released` | boolean | Whether the version is released | +| ↳ `releaseDate` | string | Release date \(YYYY-MM-DD\) | +| ↳ `resolutiondate` | string | ISO 8601 timestamp when the issue was resolved | +| ↳ `timetracking` | object | Time tracking information | +| ↳ `originalEstimate` | string | Original estimate in human-readable format \(e.g., 1w 2d\) | +| ↳ `remainingEstimate` | string | Remaining estimate in human-readable format | +| ↳ `timeSpent` | string | Time spent in human-readable format | +| ↳ `originalEstimateSeconds` | number | Original estimate in seconds | +| ↳ `remainingEstimateSeconds` | number | Remaining estimate in seconds | +| ↳ `timeSpentSeconds` | number | Time spent in seconds | +| ↳ `parent` | object | Parent issue \(subtask parent or epic\) | +| ↳ `id` | string | Parent issue ID | +| ↳ `key` | string | Parent issue key | +| ↳ `summary` | string | Parent issue summary | +| ↳ `issuelinks` | array | Linked issues. Only included when the fields param is set | +| ↳ `id` | string | Issue link ID | +| ↳ `type` | object | Link type information | +| ↳ `id` | string | Link type ID | +| ↳ `name` | string | Link type name \(e.g., Blocks, Relates\) | +| ↳ `inward` | string | Inward description \(e.g., is blocked by\) | +| ↳ `outward` | string | Outward description \(e.g., blocks\) | +| ↳ `inwardIssue` | object | Inward linked issue | +| ↳ `id` | string | Issue ID | +| ↳ `key` | string | Issue key | +| ↳ `statusName` | string | Issue status name | +| ↳ `summary` | string | Issue summary | +| ↳ `outwardIssue` | object | Outward linked issue | +| ↳ `id` | string | Issue ID | +| ↳ `key` | string | Issue key | +| ↳ `statusName` | string | Issue status name | +| ↳ `summary` | string | Issue summary | +| ↳ `subtasks` | array | Subtask issues. Only included when the fields param is set | +| ↳ `id` | string | Subtask issue ID | +| ↳ `key` | string | Subtask issue key | +| ↳ `summary` | string | Subtask summary | +| ↳ `statusName` | string | Subtask status name | +| ↳ `issueTypeName` | string | Subtask issue type name | +| ↳ `votes` | object | Vote information | +| ↳ `votes` | number | Number of votes | +| ↳ `hasVoted` | boolean | Whether the current user has voted | +| ↳ `watches` | object | Watch information | +| ↳ `watchCount` | number | Number of watchers | +| ↳ `isWatching` | boolean | Whether the current user is watching | +| ↳ `attachments` | array | Attachment metadata \(no file contents\). Only included when the fields param is set | +| ↳ `id` | string | Attachment ID | +| ↳ `filename` | string | Attachment file name | +| ↳ `mimeType` | string | MIME type of the attachment | +| ↳ `size` | number | File size in bytes | +| ↳ `content` | string | URL to download the attachment content | +| ↳ `thumbnail` | string | URL to the attachment thumbnail | +| ↳ `author` | object | Attachment author | +| ↳ `accountId` | string | Atlassian account ID of the user | +| ↳ `displayName` | string | Display name of the user | +| ↳ `active` | boolean | Whether the user account is active | +| ↳ `emailAddress` | string | Email address of the user | +| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) | +| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) | +| ↳ `timeZone` | string | User timezone | +| ↳ `authorName` | string | Attachment author display name | +| ↳ `created` | string | ISO 8601 timestamp when the attachment was created | +| ↳ `customFields` | json | Every customfield_* value Jira returned, keyed by field ID \(e.g., sprint, story points, team\). Only included when the fields param is set; use the names output or Get Fields to map IDs to names. | | `nextPageToken` | string | Cursor token for the next page. Null when no more results. | | `isLast` | boolean | Whether this is the last page of results | +| `names` | json | Map of field ID to display name \(e.g., customfield_10020 → "Sprint"\). Only returned when expand includes names. | | `total` | number | Always null. The Jira /search/jql endpoint does not return a total count; use isLast and nextPageToken for pagination. | @@ -1374,7 +1468,7 @@ Trigger workflow when a comment is deleted from a Jira issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `webhookSecret` | string | No | Optional secret to validate webhook deliveries from Jira using HMAC signature | -| `jqlFilter` | string | No | Filter which comment deletions trigger this workflow using JQL | +| `jqlFilter` | string | No | Filter which comment deletions trigger this workflow using JQL. Sim does not evaluate this filter: enter the same JQL in the Jira webhook's JQL field so Jira only sends matching events. | #### Output @@ -1461,7 +1555,7 @@ Trigger workflow when a comment is updated on a Jira issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `webhookSecret` | string | No | Optional secret to validate webhook deliveries from Jira using HMAC signature | -| `jqlFilter` | string | No | Filter which comment updates trigger this workflow using JQL | +| `jqlFilter` | string | No | Filter which comment updates trigger this workflow using JQL. Sim does not evaluate this filter: enter the same JQL in the Jira webhook's JQL field so Jira only sends matching events. | #### Output @@ -1548,7 +1642,7 @@ Trigger workflow when a comment is added to a Jira issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `webhookSecret` | string | No | Optional secret to validate webhook deliveries from Jira using HMAC signature | -| `jqlFilter` | string | No | Filter which issue comments trigger this workflow using JQL | +| `jqlFilter` | string | No | Filter which issue comments trigger this workflow using JQL. Sim does not evaluate this filter: enter the same JQL in the Jira webhook's JQL field so Jira only sends matching events. | #### Output @@ -1635,7 +1729,7 @@ Trigger workflow when a new issue is created in Jira | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `webhookSecret` | string | No | Optional secret to validate webhook deliveries from Jira using HMAC signature | -| `jqlFilter` | string | No | Filter which issues trigger this workflow using JQL \(Jira Query Language\) | +| `jqlFilter` | string | No | Filter which issues trigger this workflow using JQL \(Jira Query Language\). Sim does not evaluate this filter: enter the same JQL in the Jira webhook's JQL field so Jira only sends matching events. | #### Output @@ -1710,7 +1804,7 @@ Trigger workflow when an issue is deleted in Jira | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `webhookSecret` | string | No | Optional secret to validate webhook deliveries from Jira using HMAC signature | -| `jqlFilter` | string | No | Filter which issue deletions trigger this workflow using JQL | +| `jqlFilter` | string | No | Filter which issue deletions trigger this workflow using JQL. Sim does not evaluate this filter: enter the same JQL in the Jira webhook's JQL field so Jira only sends matching events. | #### Output @@ -1785,7 +1879,7 @@ Trigger workflow when an issue is updated in Jira | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `webhookSecret` | string | No | Optional secret to validate webhook deliveries from Jira using HMAC signature | -| `jqlFilter` | string | No | Filter which issue updates trigger this workflow using JQL | +| `jqlFilter` | string | No | Filter which issue updates trigger this workflow using JQL. Sim does not evaluate this filter: enter the same JQL in the Jira webhook's JQL field so Jira only sends matching events. | | `fieldFilters` | string | No | Comma-separated list of Jira field names. Only trigger when one of these fields changes. Leave empty to trigger on any field change. | #### Output @@ -2174,7 +2268,7 @@ Trigger workflow when time is logged on a Jira issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `webhookSecret` | string | No | Optional secret to validate webhook deliveries from Jira using HMAC signature | -| `jqlFilter` | string | No | Filter which worklog entries trigger this workflow using JQL | +| `jqlFilter` | string | No | Filter which worklog entries trigger this workflow using JQL. Sim does not evaluate this filter: enter the same JQL in the Jira webhook's JQL field so Jira only sends matching events. | #### Output @@ -2265,7 +2359,7 @@ Trigger workflow when a worklog entry is deleted from a Jira issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `webhookSecret` | string | No | Optional secret to validate webhook deliveries from Jira using HMAC signature | -| `jqlFilter` | string | No | Filter which worklog deletions trigger this workflow using JQL | +| `jqlFilter` | string | No | Filter which worklog deletions trigger this workflow using JQL. Sim does not evaluate this filter: enter the same JQL in the Jira webhook's JQL field so Jira only sends matching events. | #### Output @@ -2356,7 +2450,7 @@ Trigger workflow when a worklog entry is updated on a Jira issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `webhookSecret` | string | No | Optional secret to validate webhook deliveries from Jira using HMAC signature | -| `jqlFilter` | string | No | Filter which worklog updates trigger this workflow using JQL | +| `jqlFilter` | string | No | Filter which worklog updates trigger this workflow using JQL. Sim does not evaluate this filter: enter the same JQL in the Jira webhook's JQL field so Jira only sends matching events. | #### Output diff --git a/apps/docs/content/docs/integrations/jira_service_management.mdx b/apps/docs/content/docs/integrations/jira_service_management.mdx index e673dbb4920..b9e2401c7d6 100644 --- a/apps/docs/content/docs/integrations/jira_service_management.mdx +++ b/apps/docs/content/docs/integrations/jira_service_management.mdx @@ -98,7 +98,8 @@ Get all service desks from Jira Service Management | ↳ `projectId` | string | Associated Jira project ID | | ↳ `projectName` | string | Associated project name | | ↳ `projectKey` | string | Associated project key | -| ↳ `name` | string | Service desk name | +| ↳ `projectTypeKey` | string | Associated project type key \(e.g., service_desk\) | +| ↳ `name` | string | Service desk name \(not returned by the Service Desk API; use projectName\) | | ↳ `description` | string | Service desk description | | ↳ `leadDisplayName` | string | Project lead display name | | `total` | number | Total number of service desks | @@ -177,8 +178,14 @@ Create a new service request in Jira Service Management | `createdDate` | json | Creation date with iso8601, friendly, epochMillis | | `currentStatus` | json | Current status with status name and category | | `reporter` | json | Reporter user with accountId, displayName, emailAddress | +| `requestFieldValues` | array | Field values stored on the created request | +| ↳ `fieldId` | string | Field identifier | +| ↳ `label` | string | Human-readable field label | +| ↳ `value` | json | Field value | +| ↳ `renderedValue` | json | HTML-rendered field value | | `success` | boolean | Whether the request was created successfully | | `url` | string | URL to the created request | +| `webUrl` | string | Customer portal URL for the request \(from _links.web\) | @@ -194,7 +201,7 @@ Get a single service request from Jira Service Management | --------- | ---- | -------- | ----------- | | `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | | `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) | -| `expand` | string | No | Comma-separated fields to expand: participant, status, sla, requestType, serviceDesk, attachment, comment, action | +| `expand` | string | No | Comma-separated fields to expand: participant, status, sla, requestType, serviceDesk, attachment, comment, action, comment.attachment, comment.renderedBody | #### Output @@ -221,6 +228,18 @@ Get a single service request from Jira Service Management | ↳ `value` | json | Field value | | ↳ `renderedValue` | json | HTML-rendered field value | | `url` | string | URL to the request | +| `webUrl` | string | Customer portal URL for the request \(from _links.web\) | +| `participants` | array | Request participants \(returned when expand includes participant\) | +| ↳ `accountId` | string | Atlassian account ID | +| ↳ `displayName` | string | User display name | +| ↳ `emailAddress` | string | User email address | +| ↳ `active` | boolean | Whether the account is active | +| `slas` | array | SLA records \(returned when expand includes sla\) | +| ↳ `id` | string | SLA metric ID | +| ↳ `name` | string | SLA metric name | +| ↳ `slaDisplayFormat` | string | How the SLA time is displayed \(e.g., NEW_SLA_FORMAT\) | +| ↳ `completedCycles` | json | Completed SLA cycles with startTime, stopTime, breachTime, breached, goalDuration, elapsedTime, remainingTime \(each time as DateDTO, durations as DurationDTO\) | +| ↳ `ongoingCycle` | json | Ongoing SLA cycle with startTime, breachTime, breached, paused, withinCalendarHours, goalDuration, elapsedTime, remainingTime | | `request` | json | The service request object | @@ -237,8 +256,10 @@ Get multiple service requests from Jira Service Management | --------- | ---- | -------- | ----------- | | `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | | `serviceDeskId` | string | No | Filter by service desk ID \(e.g., "1", "2"\) | -| `requestOwnership` | string | No | Filter by ownership: OWNED_REQUESTS, PARTICIPATED_REQUESTS, APPROVER, ALL_REQUESTS | +| `requestOwnership` | string | No | Filter by ownership: OWNED_REQUESTS, PARTICIPATED_REQUESTS, APPROVER, ORGANIZATION \(with organizationId\), ALL_ORGANIZATIONS, ALL_REQUESTS | | `requestStatus` | string | No | Filter by status: OPEN_REQUESTS, CLOSED_REQUESTS, ALL_REQUESTS | +| `approvalStatus` | string | No | Filter approver requests: MY_PENDING_APPROVAL or MY_HISTORY_APPROVAL \(only with requestOwnership APPROVER\) | +| `organizationId` | string | No | Filter by customer organization ID \(only with requestOwnership ORGANIZATION\) | | `requestTypeId` | string | No | Filter by request type ID | | `searchTerm` | string | No | Search term to filter requests \(e.g., "password reset", "laptop"\) | | `expand` | string | No | Comma-separated fields to expand: participant, status, sla, requestType, serviceDesk, attachment, comment, action | @@ -270,6 +291,12 @@ Get multiple service requests from Jira Service Management | ↳ `label` | string | Human-readable field label | | ↳ `value` | json | Field value | | ↳ `renderedValue` | json | HTML-rendered field value | +| ↳ `_links` | json | Request links: web \(customer portal URL\), agent, jiraRest, self | +| ↳ `participants` | json | Paged participants \(values, size, isLastPage\) when expand includes participant | +| ↳ `sla` | json | Paged SLA records \(values with ongoingCycle, completedCycles\) when expand includes sla | +| ↳ `status` | json | Paged status history when expand includes status | +| ↳ `requestType` | json | Request type details when expand includes requestType | +| ↳ `serviceDesk` | json | Service desk details when expand includes serviceDesk | | `total` | number | Total number of requests in current page | | `isLastPage` | boolean | Whether this is the last page | @@ -428,6 +455,9 @@ Get organizations for a service desk in Jira Service Management | `organizations` | array | List of organizations | | ↳ `id` | string | Organization ID | | ↳ `name` | string | Organization name | +| ↳ `uuid` | string | Organization UUID | +| ↳ `created` | json | Creation date with iso8601, friendly, epochMillis | +| ↳ `scimManaged` | boolean | Whether the organization is managed by SCIM | | `total` | number | Total number of organizations | | `isLastPage` | boolean | Whether this is the last page | @@ -538,6 +568,7 @@ Get SLA information for a service request in Jira Service Management | `slas` | array | List of SLA metrics | | ↳ `id` | string | SLA metric ID | | ↳ `name` | string | SLA metric name | +| ↳ `slaDisplayFormat` | string | How the SLA time is displayed \(e.g., NEW_SLA_FORMAT\) | | ↳ `completedCycles` | json | Completed SLA cycles with startTime, stopTime, breachTime, breached, goalDuration, elapsedTime, remainingTime \(each time as DateDTO, durations as DurationDTO\) | | ↳ `ongoingCycle` | json | Ongoing SLA cycle with startTime, breachTime, breached, paused, withinCalendarHours, goalDuration, elapsedTime, remainingTime | | `total` | number | Total number of SLAs | @@ -1272,7 +1303,7 @@ Search Assets (Insight/CMDB) objects using AQL (Assets Query Language), e.g. obj | `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | | `qlQuery` | string | Yes | AQL query string \(e.g., objectType = "Host" AND "Operating System" = "Ubuntu"\) | | `page` | number | No | Page number \(1-based, defaults to 1\) | -| `resultsPerPage` | number | No | Results per page \(e.g., 25, 50\) | +| `resultsPerPage` | number | No | Results per page \(e.g., 25, 50; defaults to 25\) | | `includeAttributes` | boolean | No | Include resolved attribute values on each object \(defaults to true\) | | `objectTypeId` | string | No | Optionally scope the search to a single object type ID | | `objectSchemaId` | string | No | Optionally scope the search to a single object schema ID | @@ -1288,9 +1319,12 @@ Search Assets (Insight/CMDB) objects using AQL (Assets Query Language), e.g. obj | ↳ `objectKey` | string | Object key \(e.g., HOST-123\) | | ↳ `objectType` | json | Object type metadata | | ↳ `attributes` | json | Resolved attribute values | -| `total` | number | Total number of matching objects \(totalFilterCount\) | +| `total` | number | Total number of matching objects | | `pageNumber` | number | Current page number | | `pageSize` | number | Number of objects on this page | +| `startAt` | number | Index of the first object on this page | +| `maxResults` | number | Maximum objects requested for this page | +| `isLast` | boolean | Whether this is the last page of results | diff --git a/apps/docs/content/docs/integrations/kalshi.mdx b/apps/docs/content/docs/integrations/kalshi.mdx index 47b260ac1cc..3692ba72076 100644 --- a/apps/docs/content/docs/integrations/kalshi.mdx +++ b/apps/docs/content/docs/integrations/kalshi.mdx @@ -2,7 +2,7 @@ title: Kalshi description: Access prediction markets and trade on Kalshi integration: - type: "kalshi_v2" + type: "kalshi_v3" color: "#09C285" --- @@ -11,6 +11,8 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Kalshi](https://kalshi.com) in Sim to retrieve market and event data, inspect account balances and positions, read orders and trades, and place, cancel, or amend orders. The reference also covers orderbooks, candlesticks, fills, series, and exchange status. + +Market, event, orderbook, trade, series, and exchange data are public and need no credential. Balance, position, order, fill, and settlement reads, and placing, cancelling, or amending orders, are signed with a Kalshi API key saved once as a Kalshi credential: in Kalshi, open **Account Settings > Profile Settings > API Keys**, create a key, and paste its key ID and the private key Kalshi shows only once. Ed25519 keys (Kalshi's default) and RSA keys both work. Sim checks the key by reading the account balance. The key can trade for your account, so share the credential only with people who may place orders. Workflows built with the earlier Kalshi block keep the key they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -240,8 +242,6 @@ Retrieve your account balance and portfolio value from Kalshi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `keyId` | string | Yes | Your Kalshi API Key ID | -| `privateKey` | string | Yes | Your RSA Private Key \(PEM format\) | #### Output @@ -263,8 +263,6 @@ Retrieve your open positions from Kalshi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `keyId` | string | Yes | Your Kalshi API Key ID | -| `privateKey` | string | Yes | Your RSA Private Key \(PEM format\) | | `ticker` | string | No | Filter by market ticker \(e.g., "KXBTC-24DEC31"\) | | `eventTicker` | string | No | Filter by event ticker, max 10 comma-separated \(e.g., "KXBTC-24DEC31,INX-25JAN03"\) | | `countFilter` | string | No | Restrict to positions with non-zero values for the given fields \(comma-separated\): "position", "total_traded" | @@ -306,8 +304,6 @@ Retrieve your orders from Kalshi with optional filtering | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `keyId` | string | Yes | Your Kalshi API Key ID | -| `privateKey` | string | Yes | Your RSA Private Key \(PEM format\) | | `ticker` | string | No | Filter by market ticker \(e.g., "KXBTC-24DEC31"\) | | `eventTicker` | string | No | Filter by event ticker, max 10 comma-separated \(e.g., "KXBTC-24DEC31,INX-25JAN03"\) | | `status` | string | No | Filter by order status: "resting", "canceled", or "executed" | @@ -354,8 +350,6 @@ Retrieve details of a specific order by ID from Kalshi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `keyId` | string | Yes | Your Kalshi API Key ID | -| `privateKey` | string | Yes | Your RSA Private Key \(PEM format\) | | `orderId` | string | Yes | Order ID to retrieve \(e.g., "abc123-def456-ghi789"\) | #### Output @@ -519,8 +513,6 @@ Retrieve your portfolio's fills/trades from Kalshi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `keyId` | string | Yes | Your Kalshi API Key ID | -| `privateKey` | string | Yes | Your RSA Private Key \(PEM format\) | | `ticker` | string | No | Filter by market ticker \(e.g., "KXBTC-24DEC31"\) | | `orderId` | string | No | Filter by order ID \(e.g., "abc123-def456-ghi789"\) | | `minTs` | number | No | Minimum timestamp in Unix seconds \(e.g., 1704067200\) | @@ -558,8 +550,6 @@ Retrieve your portfolio settlement history from Kalshi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `keyId` | string | Yes | Your Kalshi API Key ID | -| `privateKey` | string | Yes | Your RSA Private Key \(PEM format\) | | `ticker` | string | No | Filter by market ticker \(e.g., "KXBTC-24DEC31"\) | | `eventTicker` | string | No | Filter by event ticker \(e.g., "KXBTC-24DEC31"\) | | `minTs` | number | No | Minimum settled timestamp in Unix seconds \(e.g., 1704067200\) | @@ -728,8 +718,6 @@ Create a new order on a Kalshi prediction market | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `keyId` | string | Yes | Your Kalshi API Key ID | -| `privateKey` | string | Yes | Your RSA Private Key \(PEM format\) | | `ticker` | string | Yes | Market ticker identifier \(e.g., "KXBTC-24DEC31", "INX-25JAN03-T4485.99"\) | | `side` | string | Yes | Side of the order: "yes" or "no" | | `action` | string | Yes | Action type: "buy" or "sell" | @@ -802,8 +790,6 @@ Cancel an existing order on Kalshi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `keyId` | string | Yes | Your Kalshi API Key ID | -| `privateKey` | string | Yes | Your RSA Private Key \(PEM format\) | | `orderId` | string | Yes | Order ID to cancel \(e.g., "abc123-def456-ghi789"\) | #### Output @@ -859,8 +845,6 @@ Modify the price or quantity of an existing order on Kalshi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `keyId` | string | Yes | Your Kalshi API Key ID | -| `privateKey` | string | Yes | Your RSA Private Key \(PEM format\) | | `orderId` | string | Yes | Order ID to amend \(e.g., "abc123-def456-ghi789"\) | | `ticker` | string | Yes | Market ticker identifier \(e.g., "KXBTC-24DEC31", "INX-25JAN03-T4485.99"\) | | `side` | string | Yes | Side of the order: "yes" or "no" | diff --git a/apps/docs/content/docs/integrations/lambda.mdx b/apps/docs/content/docs/integrations/lambda.mdx index bce1356987f..11b03c06ae2 100644 --- a/apps/docs/content/docs/integrations/lambda.mdx +++ b/apps/docs/content/docs/integrations/lambda.mdx @@ -58,6 +58,7 @@ Invoke a Lambda function synchronously for its response payload, asynchronously | `logType` | string | No | Set to Tail to return the last 4 KB of the execution log | | `clientContext` | string | No | Base64-encoded JSON passed to the function in the client context object \(max 3,583 bytes\) | | `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | +| `tenantId` | string | No | Tenant identifier for a function with tenant isolation enabled \(required by those functions, rejected by others\) | #### Output @@ -116,7 +117,7 @@ Get a function's configuration, code location, tags, and reserved concurrency | --------- | ---- | ----------- | | `configuration` | json | The function's configuration \(ARN, runtime, handler, memory, state, layers, VPC, and logging settings\) | | `tagsError` | json | Why the tags could not be read, when a partial tag-read failure occurred | -| `code` | json | Presigned download URL for the deployment package, or the container image URI | +| `code` | json | Deployment package location \(repositoryType, location presigned download URL, imageUri, resolvedImageUri, sourceKmsKeyArn, and error when the code cannot be retrieved\) | | `tags` | json | The function's tags | | `reservedConcurrentExecutions` | number | Concurrency reserved for this function, if any | @@ -250,6 +251,8 @@ Update a function's settings such as memory, timeout, role, and environment vari | `snapStartApplyOn` | string | No | Set to PublishedVersions to snapshot the initialized environment when a version is published | | `logFormat` | string | No | Format the function sends CloudWatch logs in | | `logGroup` | string | No | CloudWatch log group the function sends logs to | +| `applicationLogLevel` | string | No | Minimum application log level sent to CloudWatch: TRACE, DEBUG, INFO, WARN, ERROR, or FATAL. Requires the JSON log format | +| `systemLogLevel` | string | No | Minimum system log level sent to CloudWatch: DEBUG, INFO, or WARN. Requires the JSON log format | | `revisionId` | string | No | Update the resource only if its current revision ID matches this value | #### Output diff --git a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx index 40895257e5d..92debf4e645 100644 --- a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx +++ b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx @@ -1,12 +1,12 @@ --- title: Microsoft app credentials -description: Run Microsoft Entra ID, SharePoint, Intune, Dataverse, and Dynamics 365 blocks as an app registration with a client secret, a certificate, or a keyless federated credential that trusts Sim +description: Run Microsoft Entra ID, SharePoint, Intune, Power BI, Azure Data Explorer, Dataverse, and Dynamics 365 blocks as an app registration with a client secret, a certificate, or a keyless federated credential that trusts Sim --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -A Microsoft app credential signs in as a Microsoft Entra app registration instead of a person (the OAuth 2.0 client credentials flow). One credential works for the **Azure AD** (Microsoft Entra ID), **SharePoint**, **Microsoft Intune**, **Microsoft Dataverse**, and **Dynamics 365** blocks. Connecting a Microsoft account through OAuth keeps working exactly as before; the app credential is an additional option in the same account picker. +A Microsoft app credential signs in as a Microsoft Entra app registration instead of a person (the OAuth 2.0 client credentials flow). One credential works for the **Azure AD** (Microsoft Entra ID), **SharePoint**, **Microsoft Intune**, **Power BI**, **Azure Data Explorer**, **Microsoft Dataverse**, and **Dynamics 365** blocks. Connecting a Microsoft account through OAuth keeps working exactly as before; the app credential is an additional option in the same account picker. Outlook, OneDrive, Excel, Word, Planner, and Teams act on behalf of a signed-in person and don't accept an app credential. The Azure AD **Reset password** operation also needs a signed-in admin, because Microsoft Graph doesn't support application permissions for it. @@ -20,7 +20,7 @@ Outlook, OneDrive, Excel, Word, Planner, and Teams act on behalf of a signed-in | **Client secret** | The tenant ID, client ID, and client secret | Quick setup. Secrets expire, so plan to rotate them. | | **Certificate** | The tenant ID, client ID, the certificate, and its private key | When your organization requires certificate credentials. Sim signs each token request with the key. | -Every option resolves on Sim's servers at run time, and tokens never appear in block outputs, logs, or anything a model sees. Azure AD, SharePoint, and Intune blocks get a Microsoft Graph token; Dataverse and Dynamics 365 blocks get a token for the credential's own environment. +Every option resolves on Sim's servers at run time, and tokens never appear in block outputs, logs, or anything a model sees. Azure AD, SharePoint, and Intune blocks get a Microsoft Graph token; Power BI blocks get a Power BI REST API token; Azure Data Explorer blocks get a token for the cluster each block names; Dataverse and Dynamics 365 blocks get a token for the credential's own environment. ## Register the app and grant permissions @@ -34,11 +34,23 @@ Every option resolves on Sim's servers at run time, and tokens never appear in b - SharePoint: `Sites.Read.All` or `Sites.ReadWrite.All`. Creating and editing pages needs `Sites.ReadWrite.All`. `Sites.Selected` limits the app to sites you grant it, but searching for sites doesn't work with it, so enter site IDs directly. - Intune: `DeviceManagementManagedDevices.Read.All` for devices and detected apps, `DeviceManagementConfiguration.Read.All` for compliance policies, their device statuses, and device configurations, and `DeviceManagementManagedDevices.PrivilegedOperations.All` to sync, restart, remotely lock, or retire a device. The tenant needs an active Intune license. + + Power BI doesn't use API permissions either; Microsoft recommends adding none for a Power BI service principal. A Power BI admin turns on **Service principals can use Fabric APIs** (formerly **Allow service principals to use Power BI APIs**) under **Admin portal** → **Tenant settings** → **Developer settings**, for the organization or a security group that contains the app. Then add the app to each workspace the workflows use as a **Member** or **Admin** (**Manage access** on the workspace). A service principal has no **My workspace**, and DAX queries don't run as one against semantic models with row-level security or SSO; **Execute DAX query** also needs the **Dataset Execute Queries REST API** tenant setting. + + + Azure Data Explorer doesn't use API permissions. Grant the app a database role on the cluster instead, for example `.add database viewers ('aadapp=;')`. + Dataverse and Dynamics 365 don't use API permissions. Instead, in the [Power Platform admin center](https://admin.powerplatform.microsoft.com), open the environment → **Settings** → **Users + permissions** → **Application users** → **New app user**, add this app, pick a business unit, and assign a security role. Then enter the environment URL (like `https://contoso.crm.dynamics.com`) on the credential. +## National clouds [#national-clouds] + +Choose the **Cloud** the app is registered in: **Global** (which includes Microsoft 365 GCC), **US Government (GCC High)**, **US Government (DoD)**, or **China (operated by 21Vianet)**. Each is a separate Microsoft Entra deployment, and tokens from one cloud don't work in another. Sim signs in at that cloud's endpoint (`login.microsoftonline.com`, `login.microsoftonline.us`, or `login.partner.microsoftonline.cn`) and checks the app against that cloud's Microsoft Graph when you save. + +Only **Azure Data Explorer** runs outside the global cloud: a cluster in `kusto.usgovcloudapi.net` takes a US Government credential, and one in `kusto.chinacloudapi.cn` a China credential. The Azure AD, SharePoint, Intune, Power BI, Dataverse, and Dynamics 365 blocks call global-cloud endpoints, so they refuse a national-cloud credential, and a Dataverse environment URL can be saved only on a global credential. + ## Set up a keyless federated credential [#keyless-federated-credential] @@ -46,18 +58,24 @@ Every option resolves on Sim's servers at run time, and tokens never appear in b In Sim, add a **Microsoft app credential**, choose **Federated credential (keyless, via Sim OIDC)**, and enter the tenant ID and client ID. The dialog shows an **Issuer**, **Audience**, and **Subject**. - In the app registration, go to **Certificates & secrets** → **Federated credentials** → **Add credential** → **Other issuer**. Paste the **Issuer** and the **Subject identifier** exactly as shown, set the audience to `api://AzureADTokenExchange` (the dialog's value), and give it a name. Matching is case-sensitive. + In the app registration, go to **Certificates & secrets** → **Federated credentials** → **Add credential** → **Other issuer**. Paste the **Issuer** and the **Subject identifier** exactly as shown, leave **Audience** at `api://AzureADTokenExchange` (or edit it to your custom audience, below), and give it a name. Matching is case-sensitive. Save in Sim. Sim requests a token as the app and reads the app's own service principal from Microsoft Graph, which needs no extra permission. It also checks that the app refuses a subject no credential holds, so only this credential can use the app. A new federated credential can take a few minutes to take effect; if saving fails with a matching error right after you add it, wait and try again. +### Use your own audience [#custom-audience] + +By default, Sim's token carries the audience `api://AzureADTokenExchange`, the value Microsoft recommends. To make the federated credential accept only tokens issued for your app, enter your own value in the dialog's **Audience** field (up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`, such as `api://contoso-sim`), and set the federated credential's **Audience** to the same value. Entra takes exactly one audience per federated credential and matches it case-sensitively. Sim uses that audience for every token it requests for the credential, including the checks it runs when you save. Another cloud's default audience (such as `sts.amazonaws.com`) is refused. + +To change the audience later, edit the federated credential first, then reconnect in Sim with the new value. Clearing the field on reconnect returns to the default. + An app can have up to 20 federated credentials. Each Sim credential has its own subject, and reconnecting keeps the subject by default so the federated credential keeps working. Keyless credentials are available for workspace credentials, not organization credentials. ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject microsoft-app-service-account`, trust the printed subject as above, then add `tenantId` and `clientId` (optionally `environmentUrl`) to its `keylessConfig` and pass it to `sim credentials create microsoft-app-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. +Run `sim credentials keyless-subject microsoft-app-service-account`, trust the printed subject as above, then add `tenantId` and `clientId` (optionally `environmentUrl`, `cloud` as `usgov`, `usgov_dod`, or `china`, and `audience` for a [custom audience](#custom-audience)) to its `keylessConfig` and pass it to `sim credentials create microsoft-app-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. ## Use a client secret or certificate @@ -66,11 +84,11 @@ Run `sim credentials keyless-subject microsoft-app-service-account`, trust the p ## Self-hosted deployments -Keyless credentials need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issuer Microsoft Entra can reach over the public internet. See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments) for the same settings and for rotating the signing key. App credentials use the global Microsoft Entra endpoint (`login.microsoftonline.com`) and public-cloud Dataverse environments. +Keyless credentials need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issuer Microsoft Entra can reach over the public internet. See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments) for the same settings and for rotating the signing key. App credentials sign in at the Microsoft Entra endpoint of the credential's cloud, so the server needs outbound access to it; Dataverse environments are public-cloud only. ## Troubleshooting -- **"No federated credential on this app matches…":** the federated credential's issuer, subject, or audience differs from the dialog (check case and trailing slashes), it's on a different app than the client ID, or it was added moments ago. +- **"No federated credential on this app matches…":** the federated credential's issuer, subject, or audience differs from the dialog (check case and trailing slashes; the error names the audience Sim presented), it's on a different app than the client ID, or it was added moments ago. - **"Microsoft Entra rejected the client secret":** you pasted the Secret ID, or the secret was deleted. **"This client secret has expired":** create a new one and reconnect. - **"…could not verify the signed assertion":** the certificate in Sim isn't the one uploaded to the app registration. - **"…the Dataverse environment did not accept it":** add the app as an application user of the environment with a security role. diff --git a/apps/docs/content/docs/integrations/microsoft_ad.mdx b/apps/docs/content/docs/integrations/microsoft_ad.mdx index b3ba8497c9c..127581fe7b3 100644 --- a/apps/docs/content/docs/integrations/microsoft_ad.mdx +++ b/apps/docs/content/docs/integrations/microsoft_ad.mdx @@ -82,6 +82,17 @@ List users in Azure AD (Microsoft Entra ID) | ↳ `officeLocation` | string | Office location | | ↳ `mobilePhone` | string | Mobile phone number | | ↳ `accountEnabled` | boolean | Whether the account is enabled | +| ↳ `businessPhones` | array | Business phone numbers | +| ↳ `preferredLanguage` | string | Preferred language | +| ↳ `employeeId` | string | Employee identifier | +| ↳ `employeeType` | string | Employee type \(e.g., Employee, Contractor\) | +| ↳ `companyName` | string | Company name | +| ↳ `userType` | string | User type: Member or Guest | +| ↳ `usageLocation` | string | Two-letter country code, required before a license can be assigned | +| ↳ `city` | string | City | +| ↳ `country` | string | Country or region | +| ↳ `createdDateTime` | string | When the user was created | +| ↳ `onPremisesSyncEnabled` | boolean | Whether the user is synced from on-premises Active Directory | | `userCount` | number | Number of users returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -98,6 +109,7 @@ Get a user by ID or user principal name from Azure AD | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `userId` | string | Yes | User ID or user principal name \(e.g., "user@example.com"\) | +| `includeManager` | boolean | No | Also return the user's manager \(adds $expand=manager to the request\) | #### Output @@ -115,6 +127,23 @@ Get a user by ID or user principal name from Azure AD | ↳ `officeLocation` | string | Office location | | ↳ `mobilePhone` | string | Mobile phone number | | ↳ `accountEnabled` | boolean | Whether the account is enabled | +| ↳ `businessPhones` | array | Business phone numbers | +| ↳ `preferredLanguage` | string | Preferred language | +| ↳ `employeeId` | string | Employee identifier | +| ↳ `employeeType` | string | Employee type \(e.g., Employee, Contractor\) | +| ↳ `companyName` | string | Company name | +| ↳ `userType` | string | User type: Member or Guest | +| ↳ `usageLocation` | string | Two-letter country code, required before a license can be assigned | +| ↳ `city` | string | City | +| ↳ `country` | string | Country or region | +| ↳ `createdDateTime` | string | When the user was created | +| ↳ `onPremisesSyncEnabled` | boolean | Whether the user is synced from on-premises Active Directory | +| `manager` | object | The user's manager, returned only when includeManager is true; null when no manager is assigned | +| ↳ `id` | string | Manager object ID | +| ↳ `displayName` | string | Manager display name | +| ↳ `mail` | string | Manager email address | +| ↳ `userPrincipalName` | string | Manager user principal name | +| ↳ `jobTitle` | string | Manager job title | @@ -379,11 +408,46 @@ List members of a group in Azure AD (Microsoft Entra ID) | ↳ `displayName` | string | Display name | | ↳ `mail` | string | Email address | | ↳ `odataType` | string | Directory object type | +| ↳ `userPrincipalName` | string | User principal name, null for members that are not users | | `memberCount` | number | Number of members returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | +### List Azure AD User Memberships + +List the groups, directory roles, and administrative units a user is a member of in Azure AD (Microsoft Entra ID) + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | No | User ID or user principal name. Required unless continuing from a nextLink. | +| `transitive` | boolean | No | Include memberships inherited through nested groups \(transitiveMemberOf\). Defaults to direct memberships only. | +| `top` | number | No | Maximum number of memberships to return per page \(default 100, max 999\) | +| `nextLink` | string | No | Continuation URL from a previous response's 'nextLink' output, used to fetch the next page of results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `memberships` | array | Groups, directory roles, and administrative units the user belongs to | +| ↳ `id` | string | Object ID of the group, directory role, or administrative unit | +| ↳ `displayName` | string | Display name | +| ↳ `description` | string | Description | +| ↳ `odataType` | string | Directory object type \(e.g., "#microsoft.graph.group", "#microsoft.graph.directoryRole"\) | +| ↳ `mail` | string | Email address \(groups only\) | +| ↳ `mailEnabled` | boolean | Whether mail is enabled \(groups only\) | +| ↳ `securityEnabled` | boolean | Whether security is enabled \(groups only\) | +| ↳ `groupTypes` | array | Group types \(groups only\) | +| ↳ `roleTemplateId` | string | Directory role template ID \(directory roles only\) | +| `membershipCount` | number | Number of memberships returned | +| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | + + + ### Add Azure AD Group Member Add a member to a group in Azure AD (Microsoft Entra ID) @@ -915,6 +979,7 @@ List the principals holding an administrator role. Returns up to 1000 members; t | ↳ `displayName` | string | Display name | | ↳ `mail` | string | Email address | | ↳ `odataType` | string | Directory object type | +| ↳ `userPrincipalName` | string | User principal name, null for members that are not users | | `memberCount` | number | Number of members returned | diff --git a/apps/docs/content/docs/integrations/microsoft_dataverse.mdx b/apps/docs/content/docs/integrations/microsoft_dataverse.mdx index dc1510f15eb..3babccf4e6a 100644 --- a/apps/docs/content/docs/integrations/microsoft_dataverse.mdx +++ b/apps/docs/content/docs/integrations/microsoft_dataverse.mdx @@ -345,6 +345,8 @@ Query and list records from a Microsoft Dataverse table. Supports OData query op | `top` | number | No | Maximum number of records to return \(OData $top\) | | `expand` | string | No | Navigation properties to expand \(OData $expand\) | | `count` | string | No | Set to "true" to include total record count in response \(OData $count\) | +| `pageSize` | number | No | Records per page \(Prefer: odata.maxpagesize, 1-5000\). Defaults to 100 when Max Results is not set. Dataverse ignores $top when a page size is sent. When following nextLink, pass the same pageSize used for the first page, as Dataverse requires. | +| `nextLink` | string | No | nextLink returned by a previous page. When set, the other query options are ignored and the same page size must be used. It is empty on the last page; stop when empty. | #### Output @@ -354,6 +356,7 @@ Query and list records from a Microsoft Dataverse table. Supports OData query op | `count` | number | Number of records returned in the current page | | `totalCount` | number | Total number of matching records server-side \(requires $count=true\) | | `nextLink` | string | URL for the next page of results | +| `pageSize` | number | Page size sent as odata.maxpagesize \(null when Max Results applied instead\); pass it with nextLink, since Dataverse requires the same value on every page | | `success` | boolean | Operation success status | @@ -434,12 +437,14 @@ Update an existing record in a Microsoft Dataverse table. Only send the columns | `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) | | `recordId` | string | Yes | The unique identifier \(GUID\) of the record to update | | `data` | object | Yes | Record data to update as a JSON object with column names as keys | +| `returnRecord` | boolean | No | Return the updated record, including formatted values \(Prefer: return=representation\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `recordId` | string | The ID of the updated record | +| `record` | object | Dataverse record object. Contains dynamic columns based on the queried table, plus OData metadata fields. | | `success` | boolean | Operation success status | diff --git a/apps/docs/content/docs/integrations/microsoft_excel.mdx b/apps/docs/content/docs/integrations/microsoft_excel.mdx index e42816621c8..3e2eb0fd96e 100644 --- a/apps/docs/content/docs/integrations/microsoft_excel.mdx +++ b/apps/docs/content/docs/integrations/microsoft_excel.mdx @@ -524,6 +524,7 @@ Read data from a specific sheet in a Microsoft Excel spreadsheet | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `valuesOnly` | boolean | No | For used-range reads \(no cell range given\): true \(default\) counts only cells with values; false also counts cells that only have formatting. | | `sessionId` | string | No | Workbook session ID from Create Session. Omit for a sessionless request that saves changes. | | `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook to read from \(e.g., "01ABC123DEF456"\) | | `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses the signed-in user’s default OneDrive. Excel workbook APIs require a business account. | diff --git a/apps/docs/content/docs/integrations/microsoft_planner.mdx b/apps/docs/content/docs/integrations/microsoft_planner.mdx index 2ed4df08d66..cfa5fae1629 100644 --- a/apps/docs/content/docs/integrations/microsoft_planner.mdx +++ b/apps/docs/content/docs/integrations/microsoft_planner.mdx @@ -57,6 +57,33 @@ Read tasks from Microsoft Planner - get all user tasks or all tasks from a speci | --------- | ---- | ----------- | | `success` | boolean | Whether tasks were retrieved successfully | | `tasks` | array | Array of task objects with filtered properties | +| ↳ `id` | string | Task ID | +| ↳ `title` | string | Task title | +| ↳ `planId` | string | Parent plan ID | +| ↳ `bucketId` | string | Bucket ID | +| ↳ `percentComplete` | number | Completion percentage; 100 means completed | +| ↳ `priority` | number | Priority from 0 \(highest\) to 10 \(lowest\); Planner uses 1 urgent, 3 important, 5 medium, 9 low | +| ↳ `dueDateTime` | string | Due date \(ISO 8601\) | +| ↳ `createdDateTime` | string | Creation time \(ISO 8601\) | +| ↳ `completedDateTime` | string | Completion time \(ISO 8601\) | +| ↳ `hasDescription` | boolean | Whether the task details have a description | +| ↳ `assignments` | array | User IDs the task is assigned to | +| ↳ `etag` | string | Task ETag for Update Task and Delete Task \(not for Update Task Details\) | +| ↳ `startDateTime` | string | Start date \(ISO 8601\) | +| ↳ `orderHint` | string | Order hint | +| ↳ `assigneePriority` | string | Order hint for the assignee view | +| ↳ `appliedCategories` | array | Applied category labels, such as category1 | +| ↳ `previewType` | string | Preview type shown on the task card | +| ↳ `checklistItemCount` | number | Number of checklist items | +| ↳ `activeChecklistItemCount` | number | Number of unchecked checklist items | +| ↳ `referenceCount` | number | Number of external references | +| ↳ `conversationThreadId` | string | Group conversation thread ID for the task | +| ↳ `createdBy` | string | User ID of the task creator | +| ↳ `completedBy` | string | User ID of the user who completed the task | +| ↳ `assignees` | array | Assignment details per assignee | +| ↳ `userId` | string | Assignee user ID | +| ↳ `assignedDateTime` | string | When the user was assigned | +| ↳ `assignedBy` | string | User ID of who made the assignment | | `metadata` | object | Metadata including planId, userId, and planUrl | | ↳ `planId` | string | Plan ID | | ↳ `userId` | string | User ID | diff --git a/apps/docs/content/docs/integrations/microsoft_teams.mdx b/apps/docs/content/docs/integrations/microsoft_teams.mdx index 7b5cc1f9aee..a330240c3a6 100644 --- a/apps/docs/content/docs/integrations/microsoft_teams.mdx +++ b/apps/docs/content/docs/integrations/microsoft_teams.mdx @@ -67,6 +67,8 @@ Read content from a Microsoft Teams chat | --------- | ---- | -------- | ----------- | | `chatId` | string | Yes | The ID of the chat to read from \(e.g., "19:abc123def456@thread.v2" - from chat listings\) | | `includeAttachments` | boolean | No | Download and include message attachments \(hosted contents\) into storage | +| `top` | number | No | Number of messages to return per page \(1-50, default 50\) | +| `pageToken` | string | No | Page token from a previous read \(nextPageToken\) to fetch older messages | #### Output @@ -75,10 +77,12 @@ Read content from a Microsoft Teams chat | `success` | boolean | Teams chat read operation success status | | `messageCount` | number | Number of messages retrieved from chat | | `chatId` | string | ID of the chat that was read from | -| `messages` | array | Array of chat message objects | +| `messages` | array | Array of chat message objects \(also available at metadata.messages\). Each has id, content, sender, timestamp, messageType, attachments, from \(user/application/device\), senderId, senderType, replyToId, subject, importance, webUrl, contentType, lastModifiedDateTime, lastEditedDateTime, deletedDateTime, mentions, and reactions | | `attachmentCount` | number | Total number of attachments found | | `attachmentTypes` | array | Types of attachments found | | `content` | string | Formatted content of chat messages | +| `hasMore` | boolean | Whether Graph indicated more messages beyond this page | +| `nextPageToken` | string | Token to pass as pageToken to fetch the next page, or null on the last page | | `attachments` | file[] | Uploaded attachments for convenience \(flattened\) | @@ -96,6 +100,7 @@ Write or update content in a Microsoft Teams chat | `chatId` | string | Yes | The ID of the chat to write to \(e.g., "19:abc123def456@thread.v2" - from chat listings\) | | `content` | string | Yes | The content to write to the message \(plain text or HTML formatted, supports @mentions\) | | `files` | file[] | No | Files to attach to the message | +| `contentType` | string | No | Body format: "text" \(default\) or "html". Mentions and attachments always send HTML | #### Output @@ -107,6 +112,8 @@ Write or update content in a Microsoft Teams chat | `createdTime` | string | Timestamp when message was created | | `url` | string | Web URL to the message | | `updatedContent` | boolean | Whether content was successfully updated | +| `content` | string | Content of the sent message | +| `attachmentCount` | number | Number of attachments sent with the message | | `files` | file[] | Files attached to the message | @@ -124,6 +131,9 @@ Read content from a Microsoft Teams channel | `teamId` | string | Yes | The ID of the team to read from \(e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings\) | | `channelId` | string | Yes | The ID of the channel to read from \(e.g., "19:abc123def456@thread.tacv2" - from channel listings\) | | `includeAttachments` | boolean | No | Download and include message attachments \(hosted contents\) into storage | +| `includeReplies` | boolean | No | Include thread replies for each root message \(Graph caps the replies returned per message; hasMoreReplies is true when more exist\) | +| `top` | number | No | Number of root messages to return per page \(1-50, default 50\) | +| `pageToken` | string | No | Page token from a previous read \(nextPageToken\) to fetch the next page | #### Output @@ -133,10 +143,12 @@ Read content from a Microsoft Teams channel | `messageCount` | number | Number of messages retrieved from channel | | `teamId` | string | ID of the team that was read from | | `channelId` | string | ID of the channel that was read from | -| `messages` | array | Array of channel message objects | +| `messages` | array | Array of channel message objects \(also available at metadata.messages\). Each has id, content, sender, timestamp, messageType, attachments, from \(user/application/device\), senderId, senderType, replyToId, subject, summary, importance, webUrl, contentType, lastModifiedDateTime, lastEditedDateTime, deletedDateTime, mentions, reactions, and \(with includeReplies\) replies and hasMoreReplies | | `attachmentCount` | number | Total number of attachments found | | `attachmentTypes` | array | Types of attachments found | | `content` | string | Formatted content of channel messages | +| `hasMore` | boolean | Whether Graph indicated more messages beyond this page | +| `nextPageToken` | string | Token to pass as pageToken to fetch the next page, or null on the last page | | `attachments` | file[] | Uploaded attachments for convenience \(flattened\) | @@ -155,6 +167,7 @@ Write or send a message to a Microsoft Teams channel | `channelId` | string | Yes | The ID of the channel to write to \(e.g., "19:abc123def456@thread.tacv2" - from channel listings\) | | `content` | string | Yes | The content to write to the channel \(plain text or HTML formatted, supports @mentions\) | | `files` | file[] | No | Files to attach to the message | +| `contentType` | string | No | Body format: "text" \(default\) or "html". Mentions and attachments always send HTML | #### Output @@ -167,6 +180,8 @@ Write or send a message to a Microsoft Teams channel | `createdTime` | string | Timestamp when message was created | | `url` | string | Web URL to the message | | `updatedContent` | boolean | Whether content was successfully updated | +| `content` | string | Content of the sent message | +| `attachmentCount` | number | Number of attachments sent with the message | | `files` | file[] | Files attached to the message | @@ -183,7 +198,8 @@ Update an existing message in a Microsoft Teams chat | --------- | ---- | -------- | ----------- | | `chatId` | string | Yes | The ID of the chat containing the message \(e.g., "19:abc123def456@thread.v2" - from chat listings\) | | `messageId` | string | Yes | The ID of the message to update \(e.g., "1234567890123" - a numeric string from message responses\) | -| `content` | string | Yes | The new content for the message \(plain text or HTML formatted\) | +| `content` | string | Yes | The new content for the message \(plain text, or HTML when contentType is "html"\) | +| `contentType` | string | No | Body format: "text" \(default\) or "html" | #### Output @@ -208,7 +224,8 @@ Update an existing message in a Microsoft Teams channel | `teamId` | string | Yes | The ID of the team \(e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings or channel info\) | | `channelId` | string | Yes | The ID of the channel containing the message \(e.g., "19:abc123def456@thread.tacv2" - from channel listings\) | | `messageId` | string | Yes | The ID of the message to update \(e.g., "1234567890123" - a numeric string from message responses\) | -| `content` | string | Yes | The new content for the message \(plain text or HTML formatted\) | +| `content` | string | Yes | The new content for the message \(plain text, or HTML when contentType is "html"\) | +| `contentType` | string | No | Body format: "text" \(default\) or "html" | #### Output @@ -280,7 +297,8 @@ Reply to an existing message in a Microsoft Teams channel | `teamId` | string | Yes | The ID of the team \(e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings or channel info\) | | `channelId` | string | Yes | The ID of the channel \(e.g., "19:abc123def456@thread.tacv2" - from channel listings\) | | `messageId` | string | Yes | The ID of the message to reply to \(e.g., "1234567890123" - a numeric string from message responses\) | -| `content` | string | Yes | The reply content \(plain text or HTML formatted message\) | +| `content` | string | Yes | The reply content \(plain text, or HTML when contentType is "html"\) | +| `contentType` | string | No | Body format: "text" \(default\) or "html" | #### Output @@ -289,6 +307,9 @@ Reply to an existing message in a Microsoft Teams channel | `success` | boolean | Whether the reply was successful | | `messageId` | string | ID of the reply message | | `updatedContent` | boolean | Whether content was successfully sent | +| `createdTime` | string | Timestamp when the reply was created | +| `url` | string | Web URL to the reply | +| `replyToId` | string | ID of the parent message | @@ -323,6 +344,25 @@ Get a specific message from a Microsoft Teams chat or channel | ↳ `chatId` | string | Chat ID | | ↳ `messages` | array | Array of message details | | ↳ `messageCount` | number | Number of messages | +| `messageId` | string | Message ID | +| `sender` | string | Sender display name | +| `messageType` | string | Graph message type | +| `createdTime` | string | Message creation timestamp | +| `url` | string | Web URL to the message | +| `from` | json | Sender identity set: user, application \(bot\), or device, each with id and displayName | +| `senderId` | string | ID of the sending user or application | +| `senderType` | string | Sender kind: user, application, or device | +| `replyToId` | string | ID of the parent message for channel replies | +| `subject` | string | Message subject | +| `summary` | string | Message summary \(channel messages\) | +| `importance` | string | normal, high, or urgent | +| `webUrl` | string | Link to the message in Teams | +| `contentType` | string | Body content type: text or html | +| `lastModifiedDateTime` | string | When the message or its reactions last changed | +| `lastEditedDateTime` | string | When the message was last edited | +| `deletedDateTime` | string | When the message was deleted | +| `mentions` | array | Entities mentioned in the message \(id, mentionText, mentioned\) | +| `reactions` | array | Reactions \(reactionType, displayName, createdDateTime, userId, userDisplayName\) | @@ -478,6 +518,8 @@ List the Microsoft Teams chats the current user is part of | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `top` | number | No | Number of chats to return per page \(1-50, default 50\) | +| `pageToken` | string | No | Page token from a previous call \(nextPageToken\) to fetch the next page | #### Output @@ -487,6 +529,7 @@ List the Microsoft Teams chats the current user is part of | `chats` | array | Array of chats the user is part of | | `chatCount` | number | Total number of chats | | `hasMore` | boolean | Whether Graph indicated additional pages beyond this response | +| `nextPageToken` | string | Token to pass as pageToken to fetch the next page, or null on the last page | @@ -597,6 +640,21 @@ Trigger workflows from Microsoft Teams chat message notifications via Microsoft | `text` | string | Message body \(HTML or text\) | | `created_at` | string | Message timestamp | | `attachments` | file[] | Uploaded attachments as files | +| `change_type` | string | Graph change type that fired the notification: created or updated | +| `from_id` | string | Sender user or application \(bot\) ID | +| `from_type` | string | Sender kind: user, application, or device | +| `from_identity` | json | Sender identity set: user, application \(bot\), or device, each with id and displayName | +| `message_type` | string | Graph message type \(message, systemEventMessage\) | +| `content_type` | string | Body content type: text or html | +| `subject` | string | Message subject | +| `importance` | string | Message importance: normal, high, or urgent | +| `web_url` | string | Link to the message in Teams | +| `last_modified_at` | string | When the message or its reactions last changed | +| `last_edited_at` | string | When the message was last edited | +| `deleted_at` | string | When the message was deleted | +| `mentions` | json | Mentioned entities: id, mentionText, mentioned \(user/application/...\) | +| `reactions` | json | Reactions: reactionType, displayName, createdDateTime, userId, userDisplayName | +| `attachment_metadata` | json | Attachment references from Graph \(id, contentType, contentUrl, name\) | diff --git a/apps/docs/content/docs/integrations/microsoft_word.mdx b/apps/docs/content/docs/integrations/microsoft_word.mdx index cd1ac3e0eef..6482e1a19ac 100644 --- a/apps/docs/content/docs/integrations/microsoft_word.mdx +++ b/apps/docs/content/docs/integrations/microsoft_word.mdx @@ -107,6 +107,10 @@ Create a new Microsoft Word (.docx) document in OneDrive or SharePoint from text | ↳ `size` | number | Document size in bytes | | ↳ `createdTime` | string | ISO 8601 creation time | | ↳ `modifiedTime` | string | ISO 8601 last modification time | +| ↳ `driveId` | string | ID of the drive holding the document \(pass as driveId for SharePoint\) | +| ↳ `parentFolderId` | string | ID of the folder containing the document | +| ↳ `createdBy` | object | Identity that created the document \(id, displayName\) | +| ↳ `lastModifiedBy` | object | Identity that last modified the document \(id, displayName\) | @@ -140,6 +144,10 @@ Copy an existing Microsoft Word (.docx) template to a new document and fill its | ↳ `size` | number | Document size in bytes | | ↳ `createdTime` | string | ISO 8601 creation time | | ↳ `modifiedTime` | string | ISO 8601 last modification time | +| ↳ `driveId` | string | ID of the drive holding the document \(pass as driveId for SharePoint\) | +| ↳ `parentFolderId` | string | ID of the folder containing the document | +| ↳ `createdBy` | object | Identity that created the document \(id, displayName\) | +| ↳ `lastModifiedBy` | object | Identity that last modified the document \(id, displayName\) | @@ -169,6 +177,10 @@ Read the text content of a Microsoft Word (.docx) document stored in OneDrive or | ↳ `size` | number | Document size in bytes | | ↳ `createdTime` | string | ISO 8601 creation time | | ↳ `modifiedTime` | string | ISO 8601 last modification time | +| ↳ `driveId` | string | ID of the drive holding the document \(pass as driveId for SharePoint\) | +| ↳ `parentFolderId` | string | ID of the folder containing the document | +| ↳ `createdBy` | object | Identity that created the document \(id, displayName\) | +| ↳ `lastModifiedBy` | object | Identity that last modified the document \(id, displayName\) | @@ -199,6 +211,10 @@ Replace the entire contents of an existing Microsoft Word (.docx) document with | ↳ `size` | number | Document size in bytes | | ↳ `createdTime` | string | ISO 8601 creation time | | ↳ `modifiedTime` | string | ISO 8601 last modification time | +| ↳ `driveId` | string | ID of the drive holding the document \(pass as driveId for SharePoint\) | +| ↳ `parentFolderId` | string | ID of the folder containing the document | +| ↳ `createdBy` | object | Identity that created the document \(id, displayName\) | +| ↳ `lastModifiedBy` | object | Identity that last modified the document \(id, displayName\) | @@ -229,6 +245,10 @@ Append plain-text paragraphs to the end of an existing Microsoft Word (.docx) do | ↳ `size` | number | Document size in bytes | | ↳ `createdTime` | string | ISO 8601 creation time | | ↳ `modifiedTime` | string | ISO 8601 last modification time | +| ↳ `driveId` | string | ID of the drive holding the document \(pass as driveId for SharePoint\) | +| ↳ `parentFolderId` | string | ID of the folder containing the document | +| ↳ `createdBy` | object | Identity that created the document \(id, displayName\) | +| ↳ `lastModifiedBy` | object | Identity that last modified the document \(id, displayName\) | @@ -261,6 +281,10 @@ Find and replace text throughout a Microsoft Word (.docx) document, including it | ↳ `size` | number | Document size in bytes | | ↳ `createdTime` | string | ISO 8601 creation time | | ↳ `modifiedTime` | string | ISO 8601 last modification time | +| ↳ `driveId` | string | ID of the drive holding the document \(pass as driveId for SharePoint\) | +| ↳ `parentFolderId` | string | ID of the folder containing the document | +| ↳ `createdBy` | object | Identity that created the document \(id, displayName\) | +| ↳ `lastModifiedBy` | object | Identity that last modified the document \(id, displayName\) | @@ -292,6 +316,10 @@ List or search Microsoft Word (.docx) documents in OneDrive or SharePoint. Non-W | ↳ `size` | number | Document size in bytes | | ↳ `createdTime` | string | ISO 8601 creation time | | ↳ `modifiedTime` | string | ISO 8601 last modification time | +| ↳ `driveId` | string | ID of the drive holding the document \(pass as driveId for SharePoint\) | +| ↳ `parentFolderId` | string | ID of the folder containing the document | +| ↳ `createdBy` | object | Identity that created the document \(id, displayName\) | +| ↳ `lastModifiedBy` | object | Identity that last modified the document \(id, displayName\) | | `nextPageToken` | string | Continuation URL for the next page of results, when more remain | diff --git a/apps/docs/content/docs/integrations/mssql.mdx b/apps/docs/content/docs/integrations/mssql.mdx index 4ddf6d463d4..cade0cd9e34 100644 --- a/apps/docs/content/docs/integrations/mssql.mdx +++ b/apps/docs/content/docs/integrations/mssql.mdx @@ -2,7 +2,7 @@ title: Microsoft SQL Server description: Connect to Microsoft SQL Server database integration: - type: "mssql" + type: "mssql_v2" color: "#FFFFFF" --- @@ -10,7 +10,17 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} -Use [Microsoft SQL Server](https://www.microsoft.com/sql-server) to query or modify rows, execute T-SQL, and inspect tables, keys, and indexes. Configure the host, port, database, credentials, and TLS settings for your instance. +Use [Microsoft SQL Server](https://www.microsoft.com/sql-server) to query or modify rows, execute T-SQL, and inspect tables, keys, and indexes. + +### Connection setup + +In **Integrations**, add a connection with the host, port, database, username, password, and transport-security settings. Sim proves the connection with `SELECT 1` before saving it encrypted, then reads it on the server for each run; the block, the workflow, and agents never see the password. Reconnect the connection from Integrations when its credentials change. The runtime must be able to reach the database host. + +- **Encrypt connection** requests TLS; leave it enabled. A server that does not support encryption gets an unencrypted session rather than an error, so confirm the server has encryption on. +- **Trust server certificate** skips certificate validation. Enable it only for a server with a self-signed certificate you trust. +- Named instances are not supported; give the instance a static TCP port and connect to that port. + +Workflows built before saved connections keep their inline connection fields and run unchanged. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -29,13 +39,6 @@ Execute a SELECT query on a Microsoft SQL Server database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | Microsoft SQL Server hostname or IP address | -| `port` | number | No | Server port \(default: 1433\). A named instance must be reached through its static TCP port | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | -| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `query` | string | Yes | T-SQL SELECT query to execute, optionally led by a WITH clause. Statements that modify data or schema are rejected — use the Execute Raw SQL operation for those. | @@ -61,13 +64,6 @@ Insert data into a Microsoft SQL Server table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | Microsoft SQL Server hostname or IP address | -| `port` | number | No | Server port \(default: 1433\). A named instance must be reached through its static TCP port | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | -| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `table` | string | Yes | Table name to insert data into | | `data` | object | Yes | Data object to insert \(key-value pairs\) | @@ -94,13 +90,6 @@ Update rows in a Microsoft SQL Server table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | Microsoft SQL Server hostname or IP address | -| `port` | number | No | Server port \(default: 1433\). A named instance must be reached through its static TCP port | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | -| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `table` | string | Yes | Table name to update | | `data` | object | Yes | Data object with the columns to set \(key-value pairs\) | @@ -128,13 +117,6 @@ Delete rows from a Microsoft SQL Server table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | Microsoft SQL Server hostname or IP address | -| `port` | number | No | Server port \(default: 1433\). A named instance must be reached through its static TCP port | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | -| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `table` | string | Yes | Table name to delete rows from | | `where` | string | Yes | WHERE condition identifying the rows to delete | @@ -161,13 +143,6 @@ Execute a raw T-SQL statement on a Microsoft SQL Server database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | Microsoft SQL Server hostname or IP address | -| `port` | number | No | Server port \(default: 1433\). A named instance must be reached through its static TCP port | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | -| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `query` | string | Yes | T-SQL statement to execute | @@ -193,13 +168,6 @@ Introspect a Microsoft SQL Server schema to retrieve table structures, columns, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | Microsoft SQL Server hostname or IP address | -| `port` | number | No | Server port \(default: 1433\). A named instance must be reached through its static TCP port | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | -| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `schema` | string | No | Schema to introspect \(default: dbo\) | diff --git a/apps/docs/content/docs/integrations/mysql.mdx b/apps/docs/content/docs/integrations/mysql.mdx index f4624bec7da..9ec39e201cc 100644 --- a/apps/docs/content/docs/integrations/mysql.mdx +++ b/apps/docs/content/docs/integrations/mysql.mdx @@ -2,7 +2,7 @@ title: MySQL description: Connect to MySQL database integration: - type: "mysql" + type: "mysql_v2" color: "#FFFFFF" --- @@ -10,9 +10,20 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} -Use [MySQL](https://www.mysql.com/) to query rows, insert or modify records, execute raw SQL, and inspect databases, tables, columns, keys, and indexes. Configure the host, port, database, credentials, and SSL mode for the target server. +Use [MySQL](https://www.mysql.com/) to query rows, insert or modify records, execute raw SQL, and inspect databases, tables, columns, keys, and indexes. Query returns rows and a row count. Insert, update, and delete operations accept the data or WHERE clause documented below. Raw SQL can also change schema; use a database account with the permissions the workflow needs. + +### Connection setup + +In **Integrations**, add a connection with the host, port, database, username, password, and transport-security settings. Sim proves the connection with `SELECT 1` before saving it encrypted, then reads it on the server for each run; the block, the workflow, and agents never see the password. Reconnect the connection from Integrations when its credentials change. The runtime must be able to reach the database host. + +- **Preferred** encrypts without verifying the server certificate. +- **Required** encrypts and verifies the certificate chain against the runtime's trust store, but does not check the host name, like MySQL's `VERIFY_CA`. +- Both refuse a server that offers no TLS rather than connect unencrypted, so Sim's **Preferred** behaves like MySQL's `REQUIRED`, not its `PREFERRED`. +- **Disabled** never encrypts. Use it only on a network whose confidentiality you control. + +Workflows built before saved connections keep their inline connection fields and run unchanged. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -31,12 +42,6 @@ Execute SELECT query on MySQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | MySQL server hostname or IP address | -| `port` | number | Yes | MySQL server port \(default: 3306\) | -| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `query` | string | Yes | SQL SELECT query to execute \(e.g., SELECT * FROM users WHERE active = 1\) | #### Output @@ -59,12 +64,6 @@ Insert new record into MySQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | MySQL server hostname or IP address | -| `port` | number | Yes | MySQL server port \(default: 3306\) | -| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `table` | string | Yes | Table name to insert into \(e.g., users, orders\) | | `data` | object | Yes | Data to insert as key-value pairs | @@ -88,12 +87,6 @@ Update existing records in MySQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | MySQL server hostname or IP address | -| `port` | number | Yes | MySQL server port \(default: 3306\) | -| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `table` | string | Yes | Table name to update \(e.g., users, orders\) | | `data` | object | Yes | Data to update as key-value pairs | | `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | @@ -118,12 +111,6 @@ Delete records from MySQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | MySQL server hostname or IP address | -| `port` | number | Yes | MySQL server port \(default: 3306\) | -| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `table` | string | Yes | Table name to delete from \(e.g., users, orders\) | | `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | @@ -147,12 +134,6 @@ Execute raw SQL query on MySQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | MySQL server hostname or IP address | -| `port` | number | Yes | MySQL server port \(default: 3306\) | -| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `query` | string | Yes | Raw SQL query to execute \(e.g., CREATE TABLE users \(id INT PRIMARY KEY, name VARCHAR\(255\)\)\) | #### Output @@ -175,12 +156,6 @@ Introspect MySQL database schema to retrieve table structures, columns, and rela | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | MySQL server hostname or IP address | -| `port` | number | Yes | MySQL server port \(default: 3306\) | -| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | #### Output diff --git a/apps/docs/content/docs/integrations/onedrive.mdx b/apps/docs/content/docs/integrations/onedrive.mdx index 5073f893b8e..b069921b4a0 100644 --- a/apps/docs/content/docs/integrations/onedrive.mdx +++ b/apps/docs/content/docs/integrations/onedrive.mdx @@ -52,13 +52,14 @@ Upload a file to OneDrive | `content` | string | No | The text content to upload \(if no file is provided\) | | `mimeType` | string | No | The MIME type of the file to create \(e.g., text/plain for .txt, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet for .xlsx\) | | `folderId` | string | No | Folder ID to upload the file to \(e.g., "01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36M"\) | +| `conflictBehavior` | string | No | What to do when a file with the same name exists: "replace", "rename", or "fail". When unset, Graph applies its default behavior | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `success` | boolean | Whether the file was uploaded successfully | -| `file` | object | The uploaded file object with metadata including id, name, webViewLink, webContentLink, and timestamps | +| `file` | object | The uploaded file object with metadata including id, name, webViewLink, webContentLink, timestamps, parentReference, eTag, createdBy, and lastModifiedBy | @@ -80,7 +81,7 @@ Create a new folder in OneDrive | Parameter | Type | Description | | --------- | ---- | ----------- | | `success` | boolean | Whether the folder was created successfully | -| `file` | object | The created folder object with metadata including id, name, webViewLink, and timestamps | +| `file` | object | The created folder object with metadata including id, name, webViewLink, timestamps, parentReference, childCount, eTag, createdBy, and lastModifiedBy | @@ -117,7 +118,8 @@ List files and folders in OneDrive | --------- | ---- | -------- | ----------- | | `folderId` | string | No | Folder ID to list files from \(e.g., "01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36M"\) | | `query` | string | No | Filter files by name prefix \(e.g., "report", "invoice_2024"\) | -| `pageSize` | number | No | Maximum number of files to return \(e.g., 10, 50, 100\) | +| `orderBy` | string | No | OData $orderby expression to sort results \(e.g., "name", "lastModifiedDateTime desc", "size desc"\) | +| `pageSize` | number | No | Maximum number of items per page \(e.g., 10, 50, 100\). Graph returns 200 per page by default | | `pageToken` | string | No | Continuation URL from a previous response's nextPageToken, used to fetch the next page | #### Output @@ -141,7 +143,8 @@ Search for files and folders across OneDrive by name, metadata, or content (recu | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `query` | string | No | Search text matched against file name, metadata, and content. Not required when paginating with pageToken | -| `pageSize` | number | No | Maximum number of results to return \(e.g., 10, 50, 100\) | +| `orderBy` | string | No | OData $orderby expression to sort results \(e.g., "name", "lastModifiedDateTime desc"\) | +| `pageSize` | number | No | Maximum number of results per page \(e.g., 10, 50, 100\) | | `pageToken` | string | No | Continuation URL from a previous response's nextPageToken, used to fetch the next page | #### Output @@ -260,6 +263,7 @@ Create a view or edit sharing link for a OneDrive file or folder | `fileId` | string | Yes | The ID of the file or folder to share | | `linkType` | string | No | Type of link to create: "view" \(read-only\), "edit" \(read-write\), or "embed" | | `linkScope` | string | No | Who can use the link: "anonymous" \(anyone\), "organization" \(tenant members\), or "users" \(specific people\) | +| `expirationDateTime` | string | No | When the link expires, in yyyy-MM-ddTHH:mm:ssZ format \(e.g., "2026-12-31T23:59:59Z"\). Omit for no expiration unless the organization enforces one | #### Output @@ -267,6 +271,14 @@ Create a view or edit sharing link for a OneDrive file or folder | --------- | ---- | ----------- | | `success` | boolean | Whether the sharing link was created successfully | | `link` | object | The created sharing link, including its type, scope, and URL | +| ↳ `type` | string | Link type \(view, edit, or embed\) | +| ↳ `scope` | string | Link scope | +| ↳ `webUrl` | string | The sharing URL | +| ↳ `webHtml` | string | Embeddable iframe HTML \(embed links only\) | +| ↳ `permissionId` | string | ID of the permission that backs the link | +| ↳ `roles` | array | Roles the link grants \(e.g., read, write\) | +| ↳ `hasPassword` | boolean | Whether the link is password protected | +| ↳ `expirationDateTime` | string | When the link expires, if it does | diff --git a/apps/docs/content/docs/integrations/outlook.mdx b/apps/docs/content/docs/integrations/outlook.mdx index fa366be3d11..a817d224b2a 100644 --- a/apps/docs/content/docs/integrations/outlook.mdx +++ b/apps/docs/content/docs/integrations/outlook.mdx @@ -57,6 +57,8 @@ Send emails using Outlook | `replyToMessageId` | string | No | Message ID to reply to \(for threading\) | | `cc` | string | No | CC recipients \(comma-separated\) | | `bcc` | string | No | BCC recipients \(comma-separated\) | +| `replyTo` | string | No | Reply-To addresses that replies should go to \(comma-separated\) | +| `importance` | string | No | Message importance: low, normal, or high | | `attachments` | file[] | No | Files to attach to the email | #### Output @@ -86,6 +88,8 @@ Draft emails using Outlook | `contentType` | string | No | Content type for the email body \(text or html\) | | `cc` | string | No | CC recipients \(comma-separated\) | | `bcc` | string | No | BCC recipients \(comma-separated\) | +| `replyTo` | string | No | Reply-To addresses that replies should go to \(comma-separated\) | +| `importance` | string | No | Message importance: low, normal, or high | | `attachments` | file[] | No | Files to attach to the email draft | #### Output @@ -144,6 +148,29 @@ Read emails from Outlook | ↳ `hasAttachments` | boolean | Whether the message has attachments | | ↳ `isRead` | boolean | Whether the message has been read | | ↳ `importance` | string | Message importance \(low, normal, high\) | +| ↳ `bccRecipients` | array | BCC recipients \(only visible on messages you sent or drafted\) | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `replyTo` | array | Addresses replies should go to, when the sender set Reply-To | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `internetMessageId` | string | RFC 2822 Message-ID header value | +| ↳ `conversationId` | string | ID of the conversation \(thread\) the message belongs to | +| ↳ `parentFolderId` | string | ID of the mail folder containing the message | +| ↳ `categories` | array | Category names assigned to the message | +| ↳ `flag` | object | Follow-up flag | +| ↳ `flagStatus` | string | Flag status \(notFlagged, flagged, complete\) | +| ↳ `startDateTime` | object | When the follow-up starts | +| ↳ `dateTime` | string | Date and time \(ISO 8601\) | +| ↳ `timeZone` | string | Time zone name | +| ↳ `dueDateTime` | object | When the follow-up is due | +| ↳ `dateTime` | string | Date and time \(ISO 8601\) | +| ↳ `timeZone` | string | Time zone name | +| ↳ `completedDateTime` | object | When the follow-up was completed | +| ↳ `dateTime` | string | Date and time \(ISO 8601\) | +| ↳ `timeZone` | string | Time zone name | +| ↳ `isDraft` | boolean | Whether the message is a draft | +| ↳ `webLink` | string | URL that opens the message in Outlook on the web | | `attachments` | file[] | All email attachments flattened from all emails | @@ -190,6 +217,29 @@ Search Outlook messages using a free-text query (Microsoft Graph $search) | ↳ `hasAttachments` | boolean | Whether the message has attachments | | ↳ `isRead` | boolean | Whether the message has been read | | ↳ `importance` | string | Message importance \(low, normal, high\) | +| ↳ `bccRecipients` | array | BCC recipients \(only visible on messages you sent or drafted\) | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `replyTo` | array | Addresses replies should go to, when the sender set Reply-To | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `internetMessageId` | string | RFC 2822 Message-ID header value | +| ↳ `conversationId` | string | ID of the conversation \(thread\) the message belongs to | +| ↳ `parentFolderId` | string | ID of the mail folder containing the message | +| ↳ `categories` | array | Category names assigned to the message | +| ↳ `flag` | object | Follow-up flag | +| ↳ `flagStatus` | string | Flag status \(notFlagged, flagged, complete\) | +| ↳ `startDateTime` | object | When the follow-up starts | +| ↳ `dateTime` | string | Date and time \(ISO 8601\) | +| ↳ `timeZone` | string | Time zone name | +| ↳ `dueDateTime` | object | When the follow-up is due | +| ↳ `dateTime` | string | Date and time \(ISO 8601\) | +| ↳ `timeZone` | string | Time zone name | +| ↳ `completedDateTime` | object | When the follow-up was completed | +| ↳ `dateTime` | string | Date and time \(ISO 8601\) | +| ↳ `timeZone` | string | Time zone name | +| ↳ `isDraft` | boolean | Whether the message is a draft | +| ↳ `webLink` | string | URL that opens the message in Outlook on the web | @@ -205,6 +255,8 @@ Reply to the sender of an Outlook message with a comment | --------- | ---- | -------- | ----------- | | `messageId` | string | Yes | The ID of the message to reply to | | `comment` | string | No | The reply text to include above the original message | +| `cc` | string | No | CC recipients to add to the reply \(comma-separated\) | +| `bcc` | string | No | BCC recipients to add to the reply \(comma-separated\) | #### Output @@ -231,6 +283,7 @@ Reply to all recipients of an Outlook message with a comment | --------- | ---- | -------- | ----------- | | `messageId` | string | Yes | The ID of the message to reply to | | `comment` | string | No | The reply text to include above the original message | +| `bcc` | string | No | BCC recipients to add to the reply \(comma-separated\) | #### Output @@ -258,6 +311,8 @@ Forward an existing Outlook message to specified recipients | `messageId` | string | Yes | The ID of the message to forward | | `to` | string | Yes | Recipient email address\(es\), comma-separated | | `comment` | string | No | Optional comment to include with the forwarded message | +| `cc` | string | No | CC recipients for the forwarded message \(comma-separated\) | +| `bcc` | string | No | BCC recipients for the forwarded message \(comma-separated\) | #### Output @@ -832,6 +887,14 @@ Triggers when new emails are received in Outlook (requires Microsoft credentials | ↳ `folderId` | string | Outlook folder ID where email is located | | ↳ `messageId` | string | Message ID for threading | | ↳ `threadId` | string | Thread ID for conversation threading | +| ↳ `bcc` | string | BCC recipients \(only present on messages you sent\) | +| ↳ `replyTo` | string | Reply-To addresses, comma-separated \(empty when the sender set none\) | +| ↳ `internetMessageId` | string | RFC 2822 Message-ID header value | +| ↳ `importance` | string | Message importance \(low, normal, high\) | +| ↳ `categories` | array | Category names assigned to the message | +| ↳ `flagStatus` | string | Follow-up flag status \(notFlagged, flagged, complete\) | +| ↳ `bodyPreview` | string | First 255 characters of the body as plain text | +| ↳ `webLink` | string | URL that opens the message in Outlook on the web | | `timestamp` | string | Event timestamp | diff --git a/apps/docs/content/docs/integrations/postgresql.mdx b/apps/docs/content/docs/integrations/postgresql.mdx index c5ffbc47a8f..c1237fda4c7 100644 --- a/apps/docs/content/docs/integrations/postgresql.mdx +++ b/apps/docs/content/docs/integrations/postgresql.mdx @@ -2,7 +2,7 @@ title: PostgreSQL description: Connect to PostgreSQL database integration: - type: "postgresql" + type: "postgresql_v2" color: "#336791" --- @@ -11,6 +11,16 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [PostgreSQL](https://www.postgresql.org/) to query and modify rows, execute SQL, and inspect tables, columns, keys, and indexes. Schema inspection lets a workflow discover the database structure before generating a query. + +### Connection setup + +In **Integrations**, add a connection with the host, port, database, username, password, and transport-security settings. Sim proves the connection with `SELECT 1` before saving it encrypted, then reads it on the server for each run; the block, the workflow, and agents never see the password. Reconnect the connection from Integrations when its credentials change. The runtime must be able to reach the database host. + +- **Preferred** encrypts when the server offers TLS and otherwise connects without it. An attacker on the network can force that fallback, so choose **Required** when the server supports TLS. +- **Required** always encrypts but does not verify the server certificate, like libpq's `sslmode=require`. +- **Disabled** never encrypts. Use it only on a network whose confidentiality you control. + +Workflows built before saved connections keep their inline connection fields and run unchanged. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -29,12 +39,6 @@ Execute a SELECT query on PostgreSQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | PostgreSQL server hostname or IP address | -| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `query` | string | Yes | SQL SELECT query to execute | #### Output @@ -57,12 +61,6 @@ Insert data into PostgreSQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | PostgreSQL server hostname or IP address | -| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `table` | string | Yes | Table name to insert data into | | `data` | object | Yes | Data object to insert \(key-value pairs\) | @@ -86,12 +84,6 @@ Update data in PostgreSQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | PostgreSQL server hostname or IP address | -| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `table` | string | Yes | Table name to update data in | | `data` | object | Yes | Data object with fields to update \(key-value pairs\) | | `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | @@ -116,12 +108,6 @@ Delete data from PostgreSQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | PostgreSQL server hostname or IP address | -| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `table` | string | Yes | Table name to delete data from | | `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | @@ -145,12 +131,6 @@ Execute raw SQL query on PostgreSQL database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | PostgreSQL server hostname or IP address | -| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `query` | string | Yes | Raw SQL query to execute | #### Output @@ -173,12 +153,6 @@ Introspect PostgreSQL database schema to retrieve table structures, columns, and | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `host` | string | Yes | PostgreSQL server hostname or IP address | -| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | -| `database` | string | Yes | Database name to connect to | -| `username` | string | Yes | Database username | -| `password` | string | Yes | Database password | -| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | | `schema` | string | No | Schema to introspect \(default: public\) | #### Output diff --git a/apps/docs/content/docs/integrations/powerbi.mdx b/apps/docs/content/docs/integrations/powerbi.mdx index 8a637a63288..fb0042294c0 100644 --- a/apps/docs/content/docs/integrations/powerbi.mdx +++ b/apps/docs/content/docs/integrations/powerbi.mdx @@ -29,7 +29,7 @@ Sim requests these scopes when someone connects a Power BI account. For a self-h ## Use the block [#usage-instructions] -Connect a home-tenant organizational account through the Power BI Microsoft connection to list named workspaces, inspect reports and semantic models, execute DAX queries, request standard refreshes, and read refresh history. Microsoft Graph connections cannot substitute for this delegated OAuth connection. Self-hosted installations use the shared Microsoft app registration and register /api/auth/oauth2/callback/microsoft-powerbi as a Web redirect URI. Workspace listing supports a result limit, offset, and OData filter; reports and semantic models return flat lists. Read-only models may contain only their ID and name, and paginated reports may omit their semantic-model ID. Missing optional fields are null and do not establish permission or feature availability. Keep results small with summaries or TOPN: provider JSON reads are limited to 20 MiB, and workflow tool responses have a stricter 10 MiB inline budget. My Workspace, cross-tenant guest configuration, Credential Groups, service principals, impersonation, enhanced refresh, report exports, dashboards, and triggers are outside this version. +Connect a home-tenant organizational account through the Power BI Microsoft connection, or run as an app registration with a Microsoft app credential in the global cloud, to list named workspaces, inspect reports and semantic models, execute DAX queries, request standard refreshes, and read refresh history. A Microsoft Graph connection cannot substitute for the Power BI connection or app credential. An app credential needs the Power BI tenant setting that allows service principals to use Power BI APIs and its service principal added to each workspace as a Member or Admin; DAX queries do not run as a service principal against semantic models with row-level security or SSO. Self-hosted installations use the shared Microsoft app registration and register /api/auth/oauth2/callback/microsoft-powerbi as a Web redirect URI. Workspace listing supports a result limit, offset, and OData filter; reports and semantic models return flat lists. Read-only models may contain only their ID and name, and paginated reports may omit their semantic-model ID. Missing optional fields are null and do not establish permission or feature availability. Keep results small with summaries or TOPN: provider JSON reads are limited to 20 MiB, and workflow tool responses have a stricter 10 MiB inline budget. My Workspace, cross-tenant guest configuration, Credential Groups, impersonation, enhanced refresh, report exports, dashboards, and triggers are outside this version. ## Actions diff --git a/apps/docs/content/docs/integrations/qdrant.mdx b/apps/docs/content/docs/integrations/qdrant.mdx index cb3023fb45f..218aac3ddbb 100644 --- a/apps/docs/content/docs/integrations/qdrant.mdx +++ b/apps/docs/content/docs/integrations/qdrant.mdx @@ -2,7 +2,7 @@ title: Qdrant description: Use Qdrant vector database integration: - type: "qdrant" + type: "qdrant_v2" color: "#1A223F" --- @@ -29,8 +29,6 @@ Insert or update points in a Qdrant collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `url` | string | Yes | Qdrant instance URL \(e.g., https://your-cluster.qdrant.io) | -| `apiKey` | string | No | Qdrant API key for authentication | | `collection` | string | Yes | Collection name for upsert \(e.g., "my_collection"\) | | `points` | array | Yes | Array of points to upsert | @@ -55,8 +53,6 @@ Search for similar vectors in a Qdrant collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `url` | string | Yes | Qdrant instance URL \(e.g., https://your-cluster.qdrant.io) | -| `apiKey` | string | No | Qdrant API key for authentication | | `collection` | string | Yes | Collection name to search \(e.g., "my_collection"\) | | `vector` | array | Yes | Query vector for similarity search \(e.g., \[0.1, 0.2, 0.3, ...\]\) | | `limit` | number | No | Maximum number of results to return \(e.g., 10\) | @@ -91,8 +87,6 @@ Fetch points by ID from a Qdrant collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `url` | string | Yes | Qdrant instance URL \(e.g., https://your-cluster.qdrant.io) | -| `apiKey` | string | No | Qdrant API key for authentication | | `collection` | string | Yes | Collection name to fetch from \(e.g., "my_collection"\) | | `ids` | array | Yes | Array of point IDs to fetch \(e.g., \["id1", "id2"\] or \[1, 2\]\) | | `fetch_return_data` | string | No | Data to return from fetch | diff --git a/apps/docs/content/docs/integrations/railway.mdx b/apps/docs/content/docs/integrations/railway.mdx index 82b3d8759d5..63b612193da 100644 --- a/apps/docs/content/docs/integrations/railway.mdx +++ b/apps/docs/content/docs/integrations/railway.mdx @@ -2,7 +2,7 @@ title: Railway description: Manage Railway projects, services, deployments, and variables integration: - type: "railway" + type: "railway_v2" color: "#000000" --- @@ -31,8 +31,6 @@ List Railway projects visible to the provided token | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `workspaceId` | string | No | Workspace ID to list projects from | | `first` | number | No | Maximum number of projects to return | | `after` | string | No | Cursor for pagination | @@ -64,8 +62,6 @@ Get a Railway project with its services and environments | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | #### Output @@ -98,8 +94,6 @@ Create a Railway project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `name` | string | Yes | Project name | | `description` | string | No | Project description | | `workspaceId` | string | No | Workspace ID to create the project in | @@ -127,8 +121,6 @@ Update a Railway project name or description | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | | `name` | string | No | Updated project name | | `description` | string | No | Updated project description | @@ -156,8 +148,6 @@ Delete a Railway project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | #### Output @@ -178,8 +168,6 @@ Transfer a Railway project to another workspace | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | | `workspaceId` | string | Yes | Destination workspace ID | @@ -201,8 +189,6 @@ List members for a Railway project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | #### Output @@ -229,8 +215,6 @@ Create a Railway project environment | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | | `name` | string | Yes | Environment name | | `sourceEnvironmentId` | string | No | Environment ID to clone from | @@ -258,8 +242,6 @@ Delete a Railway project environment | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `environmentId` | string | Yes | Railway environment ID | #### Output @@ -280,8 +262,6 @@ Create a Railway service from a GitHub repo or Docker image | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | | `name` | string | Yes | Service name | | `repo` | string | No | GitHub repository in owner/name format to deploy from | @@ -308,8 +288,6 @@ Delete a Railway service and all of its deployments | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `serviceId` | string | Yes | Railway service ID | #### Output @@ -330,8 +308,6 @@ List deployments for a Railway service in an environment | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | | `serviceId` | string | Yes | Railway service ID | | `environmentId` | string | Yes | Railway environment ID | @@ -367,8 +343,6 @@ Get details for a single Railway deployment | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `deploymentId` | string | Yes | Railway deployment ID | #### Output @@ -396,8 +370,6 @@ Trigger a deployment for a Railway service in an environment | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `serviceId` | string | Yes | Railway service ID | | `environmentId` | string | Yes | Railway environment ID | | `commitSha` | string | No | Specific Git commit SHA to deploy | @@ -420,8 +392,6 @@ Restart a running Railway deployment without rebuilding | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `deploymentId` | string | Yes | Railway deployment ID | #### Output @@ -442,8 +412,6 @@ Roll a Railway service back to a previous deployment | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `deploymentId` | string | Yes | Railway deployment ID to roll back to \(must have canRollback\) | #### Output @@ -464,8 +432,6 @@ Retrieve runtime logs for a Railway deployment | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `deploymentId` | string | Yes | Railway deployment ID | | `limit` | number | No | Maximum number of log lines to return | @@ -491,8 +457,6 @@ List Railway environment variables for a service or shared environment | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | | `environmentId` | string | Yes | Railway environment ID | | `serviceId` | string | No | Railway service ID. Omit for shared environment variables. | @@ -516,8 +480,6 @@ Create or update a Railway environment variable | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | | `environmentId` | string | Yes | Railway environment ID | | `serviceId` | string | No | Railway service ID. Omit to create or update a shared variable. | @@ -543,8 +505,6 @@ Delete a Railway environment variable | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Railway API token | -| `tokenType` | string | No | Railway token type. Use "account" for account, workspace, or OAuth tokens, or "project" for project tokens. | | `projectId` | string | Yes | Railway project ID | | `environmentId` | string | Yes | Railway environment ID | | `name` | string | Yes | Variable name to delete | diff --git a/apps/docs/content/docs/integrations/rds.mdx b/apps/docs/content/docs/integrations/rds.mdx index 493311bd8ad..e676b851c44 100644 --- a/apps/docs/content/docs/integrations/rds.mdx +++ b/apps/docs/content/docs/integrations/rds.mdx @@ -34,6 +34,7 @@ Execute a SELECT query on Amazon RDS using the Data API | `secretArn` | string | Yes | ARN of the Secrets Manager secret containing DB credentials | | `database` | string | No | Database name to connect to \(e.g., mydb, production_db\) | | `query` | string | Yes | SQL SELECT query to execute \(e.g., SELECT * FROM users WHERE status = :status\) | +| `parameters` | json | No | Values bound to :name placeholders in the query, so input never needs to be spliced into the SQL. Either an object \(e.g., \{"status": "active", "id": 5\}\) or an array of \{name, value, typeHint\} entries, where typeHint is DATE, DECIMAL, JSON, TIME, TIMESTAMP, or UUID \(e.g., \[\{"name": "id", "value": "6f1c...", "typeHint": "UUID"\}\]\) | #### Output @@ -69,6 +70,7 @@ Insert data into an Amazon RDS table using the Data API | `message` | string | Operation status message | | `rows` | array | Array of inserted rows | | `rowCount` | number | Number of rows inserted | +| `generatedFields` | array | Values generated by the insert, such as auto-increment IDs \(Aurora MySQL only\) | @@ -142,6 +144,7 @@ Execute raw SQL on Amazon RDS using the Data API | `secretArn` | string | Yes | ARN of the Secrets Manager secret containing DB credentials | | `database` | string | No | Database name to connect to \(e.g., mydb, production_db\) | | `query` | string | Yes | Raw SQL query to execute \(e.g., CREATE TABLE users \(id SERIAL PRIMARY KEY, name VARCHAR\(255\)\)\) | +| `parameters` | json | No | Values bound to :name placeholders in the query, so input never needs to be spliced into the SQL. Either an object \(e.g., \{"status": "active", "id": 5\}\) or an array of \{name, value, typeHint\} entries, where typeHint is DATE, DECIMAL, JSON, TIME, TIMESTAMP, or UUID \(e.g., \[\{"name": "id", "value": "6f1c...", "typeHint": "UUID"\}\]\) | #### Output @@ -150,6 +153,7 @@ Execute raw SQL on Amazon RDS using the Data API | `message` | string | Operation status message | | `rows` | array | Array of rows returned or affected | | `rowCount` | number | Number of rows affected | +| `generatedFields` | array | Values generated by the statement, such as auto-increment IDs \(Aurora MySQL only; use RETURNING on PostgreSQL\) | diff --git a/apps/docs/content/docs/integrations/s3.mdx b/apps/docs/content/docs/integrations/s3.mdx index 232b598899d..a5d5efd616f 100644 --- a/apps/docs/content/docs/integrations/s3.mdx +++ b/apps/docs/content/docs/integrations/s3.mdx @@ -36,6 +36,10 @@ Upload a file to an AWS S3 bucket | `content` | string | No | Text content to upload \(alternative to file\) | | `contentType` | string | No | Content-Type header \(auto-detected from file if not provided\) | | `acl` | string | No | Access control list \(e.g., private, public-read\) | +| `metadata` | json | No | User-defined object metadata stored as x-amz-meta-* headers, as a JSON object of string values \(e.g., \{"department": "finance"\}\) | +| `storageClass` | string | No | Storage class \(e.g., STANDARD, STANDARD_IA, INTELLIGENT_TIERING, GLACIER_IR\). Defaults to STANDARD. | +| `serverSideEncryption` | string | No | Server-side encryption algorithm: AES256, aws:kms, or aws:kms:dsse | +| `sseKmsKeyId` | string | No | KMS key ID or ARN to encrypt with when serverSideEncryption is aws:kms | #### Output @@ -43,7 +47,7 @@ Upload a file to an AWS S3 bucket | --------- | ---- | ----------- | | `url` | string | URL of the uploaded S3 object | | `uri` | string | S3 URI of the uploaded object \(s3://bucket/key\) | -| `metadata` | object | Upload metadata including ETag and location | +| `metadata` | object | Upload metadata: etag, location, key, bucket, versionId \(versioned buckets\), and serverSideEncryption | @@ -66,7 +70,7 @@ Retrieve an object from an AWS S3 bucket | --------- | ---- | ----------- | | `url` | string | Pre-signed URL for downloading the S3 object | | `file` | file | Downloaded file stored in execution files | -| `metadata` | object | File metadata including type, size, name, and last modified date | +| `metadata` | object | File metadata: fileType, size, name, lastModified, etag, versionId \(versioned buckets\), storageClass \(omitted by S3 for STANDARD\), serverSideEncryption, and userMetadata \(x-amz-meta-* values keyed without the prefix\) | @@ -85,6 +89,8 @@ List objects in an AWS S3 bucket | `prefix` | string | No | Prefix to filter objects \(e.g., folder/, images/2024/\) | | `maxKeys` | number | No | Maximum number of objects to return \(default: 1000\) | | `continuationToken` | string | No | Token for pagination from previous list response | +| `delimiter` | string | No | Character that groups keys \(usually /\). Keys sharing a prefix up to the delimiter are returned once in commonPrefixes instead of in objects, like folders. | +| `startAfter` | string | No | Key to start listing after \(e.g., folder/file-100.txt\) | #### Output @@ -95,6 +101,8 @@ List objects in an AWS S3 bucket | ↳ `size` | number | Object size in bytes | | ↳ `lastModified` | string | Last modified timestamp | | ↳ `etag` | string | Entity tag | +| ↳ `storageClass` | string | Storage class \(e.g., STANDARD, GLACIER\) | +| `commonPrefixes` | array | Folder-like prefixes grouped by the delimiter \(empty without a delimiter\) | | `metadata` | object | Listing metadata including pagination info | diff --git a/apps/docs/content/docs/integrations/secrets_manager.mdx b/apps/docs/content/docs/integrations/secrets_manager.mdx index 06e16ad4ee9..0193b0ba9df 100644 --- a/apps/docs/content/docs/integrations/secrets_manager.mdx +++ b/apps/docs/content/docs/integrations/secrets_manager.mdx @@ -41,7 +41,8 @@ Retrieve a secret value from AWS Secrets Manager | Parameter | Type | Description | | --------- | ---- | ----------- | | `name` | string | Name of the secret | -| `secretValue` | string | The decrypted secret value | +| `secretValue` | string | The decrypted secret value \(empty when the secret is stored as binary\) | +| `secretBinary` | string | Base64-encoded decrypted binary secret value, when stored as SecretBinary | | `arn` | string | ARN of the secret | | `versionId` | string | Version ID of the secret | | `versionStages` | array | Staging labels attached to this version | @@ -62,12 +63,16 @@ List secrets stored in AWS Secrets Manager | `region` | string | Yes | AWS region \(e.g., us-east-1\) | | `maxResults` | number | No | Maximum number of secrets to return \(1-100, default 100\) | | `nextToken` | string | No | Pagination token from a previous request | +| `filters` | json | No | Filters as an array of \{key, values\}. key is one of description, name, tag-key, tag-value, primary-region, owning-service, all \(e.g., \[\{"key":"name","values":\["prod/"\]\}\]\) | +| `sortOrder` | string | No | Sort order: asc or desc \(default asc\) | +| `sortBy` | string | No | Field to sort by: created-date, last-accessed-date, last-changed-date, or name \(default created-date\) | +| `includePlannedDeletion` | boolean | No | Include secrets scheduled for deletion \(default false\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `secrets` | json | List of secrets with name, ARN, description, dates, rotation rules/window, and version-to-stage mappings | +| `secrets` | json | List of secrets with name, ARN, description, KMS key, rotation Lambda ARN, owning service, primary region, dates, tags, rotation rules/window, and version-to-stage mappings | | `nextToken` | string | Pagination token for the next page of results | | `count` | number | Number of secrets returned | @@ -87,6 +92,8 @@ Create a new secret in AWS Secrets Manager | `name` | string | Yes | Name of the secret to create | | `secretValue` | string | Yes | The secret value \(plain text or JSON string\) | | `description` | string | No | Description of the secret | +| `kmsKeyId` | string | No | ARN, key ID, or alias of the KMS key used to encrypt the secret \(defaults to aws/secretsmanager\) | +| `tags` | json | No | Tags to attach to the new secret, as an array of \{key, value\} pairs | #### Output @@ -113,6 +120,7 @@ Update the value of an existing secret in AWS Secrets Manager | `secretId` | string | Yes | The name or ARN of the secret to update | | `secretValue` | string | Yes | The new secret value \(plain text or JSON string\) | | `description` | string | No | Updated description of the secret | +| `kmsKeyId` | string | No | ARN, key ID, or alias of a new KMS key to encrypt the new secret version | #### Output diff --git a/apps/docs/content/docs/integrations/servicenow.mdx b/apps/docs/content/docs/integrations/servicenow.mdx index 8dc4d79cf78..7fb89918119 100644 --- a/apps/docs/content/docs/integrations/servicenow.mdx +++ b/apps/docs/content/docs/integrations/servicenow.mdx @@ -13,6 +13,8 @@ import { ReferenceDetails } from "@/components/ui/reference-details" Use [ServiceNow](https://www.servicenow.com/) in Sim to create or update incidents and change requests, order catalog items, and process approvals. Read configuration items, knowledge articles, users, and groups, and transfer attachments. Generic record and aggregate operations accept a table name, such as incident, task, change_request, or sys_user, and the fields required by the operation. List Attachments and Upload Attachment also require a table name. Connect ServiceNow once as a ServiceNow credential: enter the instance URL plus the username and password of a dedicated integration user that has the roles your workflows need and does not use multi-factor authentication. Enter the instance's https URL, such as `acme.service-now.com` or a custom instance URL; the instance must be reachable from the internet. Workflows built with the earlier ServiceNow block keep the instance URL, username, and password they entered in the block. + +To connect without a password, set **Authentication** to **OAuth client credentials** and enter the instance URL with the client ID and secret of an inbound OAuth client. A ServiceNow admin first creates the system property `glide.oauth.inbound.client.credential.grant_type.enabled` (true/false) in the global scope and sets it to `true`, then creates the OAuth client (on Zurich and later, in **Machine Identity Console > Inbound integrations > New integration > OAuth - Client credentials grant**) with an **OAuth Application User** that has the roles your workflows need. Sim requests a token from the instance's `/oauth_token.do` whenever the current one expires, and every request acts as the OAuth Application User. See ServiceNow's [client credentials](https://www.servicenow.com/docs/r/xanadu/platform-security/authentication/client-credentials.html) documentation. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/docs/content/docs/integrations/ses.mdx b/apps/docs/content/docs/integrations/ses.mdx index 3a9719dfd91..83c083bd13e 100644 --- a/apps/docs/content/docs/integrations/ses.mdx +++ b/apps/docs/content/docs/integrations/ses.mdx @@ -39,6 +39,8 @@ Send an email via AWS SES using simple or HTML content | `bccAddresses` | string | No | Comma-separated list of BCC email addresses | | `replyToAddresses` | string | No | Comma-separated list of reply-to email addresses | | `configurationSetName` | string | No | SES configuration set name for tracking | +| `fromName` | string | No | Friendly sender name shown with the From address \(e.g., Acme Support\) | +| `emailTags` | json | No | Message tags for filtering sending events, as a JSON array of \{"name", "value"\} objects \(letters, digits, _ and - only, up to 256 characters\) | #### Output @@ -65,7 +67,10 @@ Send an email using an SES email template with dynamic template data | `templateData` | string | Yes | JSON string of key-value pairs for template variable substitution | | `ccAddresses` | string | No | Comma-separated list of CC email addresses | | `bccAddresses` | string | No | Comma-separated list of BCC email addresses | +| `replyToAddresses` | string | No | Comma-separated list of reply-to email addresses | | `configurationSetName` | string | No | SES configuration set name for tracking | +| `fromName` | string | No | Friendly sender name shown with the From address \(e.g., Acme Support\) | +| `emailTags` | json | No | Message tags for filtering sending events, as a JSON array of \{"name", "value"\} objects \(letters, digits, _ and - only, up to 256 characters\) | #### Output @@ -88,9 +93,12 @@ Send emails to multiple recipients using an SES template with per-recipient data | `region` | string | Yes | AWS region \(e.g., us-east-1\) | | `fromAddress` | string | Yes | Verified sender email address | | `templateName` | string | Yes | Name of the SES email template to use | -| `destinations` | string | Yes | JSON array of destination objects with toAddresses \(string\[\]\) and optional templateData \(JSON string\); falls back to defaultTemplateData when omitted | +| `destinations` | string | Yes | JSON array of destination objects with toAddresses \(string\[\]\), optional ccAddresses and bccAddresses \(string\[\]\), and optional templateData \(JSON string\); templateData falls back to defaultTemplateData when omitted | | `defaultTemplateData` | string | No | Default JSON template data used when a destination does not specify its own | +| `replyToAddresses` | string | No | Comma-separated list of reply-to email addresses | | `configurationSetName` | string | No | SES configuration set name for tracking | +| `fromName` | string | No | Friendly sender name shown with the From address \(e.g., Acme Support\) | +| `emailTags` | json | No | Default message tags applied to every destination for filtering sending events, as a JSON array of \{"name", "value"\} objects \(letters, digits, _ and - only, up to 256 characters\) | #### Output @@ -146,6 +154,8 @@ Get SES account sending quota and status information | `max24HourSend` | number | Maximum emails allowed per 24-hour period | | `maxSendRate` | number | Maximum emails allowed per second | | `sentLast24Hours` | number | Number of emails sent in the last 24 hours | +| `productionAccessEnabled` | boolean | Whether the account has production access \(false while in the SES sandbox\) | +| `enforcementStatus` | string | Account sending status: HEALTHY, PROBATION, or SHUTDOWN | diff --git a/apps/docs/content/docs/integrations/sharepoint.mdx b/apps/docs/content/docs/integrations/sharepoint.mdx index 5d1384a8484..e45b9494301 100644 --- a/apps/docs/content/docs/integrations/sharepoint.mdx +++ b/apps/docs/content/docs/integrations/sharepoint.mdx @@ -101,6 +101,9 @@ Read a specific page from a SharePoint site | ↳ `description` | string | The description of the page | | ↳ `createdDateTime` | string | When the page was created | | ↳ `lastModifiedDateTime` | string | When the page was last modified | +| ↳ `publishingState` | object | Publishing state \(level such as draft, checkout, or published; versionId\) | +| ↳ `createdBy` | object | Identity set of the creator \(e.g. user.displayName, user.email\) | +| ↳ `lastModifiedBy` | object | Identity set of the last modifier \(e.g. user.displayName, user.email\) | | `pages` | array | List of SharePoint pages | | ↳ `page` | object | | | ↳ `id` | string | The unique ID of the page | @@ -111,6 +114,9 @@ Read a specific page from a SharePoint site | ↳ `description` | string | The description of the page | | ↳ `createdDateTime` | string | When the page was created | | ↳ `lastModifiedDateTime` | string | When the page was last modified | +| ↳ `publishingState` | object | Publishing state \(level such as draft, checkout, or published; versionId\) | +| ↳ `createdBy` | object | Identity set of the creator \(e.g. user.displayName, user.email\) | +| ↳ `lastModifiedBy` | object | Identity set of the last modifier \(e.g. user.displayName, user.email\) | | ↳ `content` | object | | | ↳ `content` | string | Extracted text content from the page | | ↳ `canvasLayout` | object | Raw SharePoint canvas layout structure | @@ -212,6 +218,7 @@ List details of all SharePoint sites | `siteSelector` | string | No | Select the SharePoint site | | `siteId` | string | No | The ID of the SharePoint site \(internal use\) | | `groupId` | string | No | The group ID for accessing a group team site. Example: a GUID like 12345678-1234-1234-1234-123456789012 | +| `searchQuery` | string | No | Keywords to search sites by when listing all sites \(defaults to * for every site\). Example: marketing | | `nextPageUrl` | string | No | Full @odata.nextLink URL from a previous Microsoft Graph page response | #### Output @@ -289,6 +296,9 @@ Get metadata (and optionally columns/items) for a SharePoint list | `listId` | string | No | The ID of the list to retrieve. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012 | | `includeColumns` | boolean | No | Whether to include column definitions when retrieving a specific list | | `includeItems` | boolean | No | Whether to include list items when retrieving a specific list | +| `filter` | string | No | OData $filter applied when reading list items. Reference columns as fields/<InternalName>. Example: fields/Status eq 'Active' | +| `top` | number | No | Maximum number of list items to return per page \(1-5000\) | +| `selectFields` | string | No | Comma-separated column internal names to return in each item's fields. Example: Title,Status,DueDate | | `nextPageUrl` | string | No | Full @odata.nextLink URL from a previous Microsoft Graph page response | #### Output @@ -296,21 +306,8 @@ Get metadata (and optionally columns/items) for a SharePoint list | Parameter | Type | Description | | --------- | ---- | ----------- | | `list` | object | Information about the SharePoint list | -| ↳ `id` | string | The unique ID of the list | -| ↳ `displayName` | string | The display name of the list | -| ↳ `name` | string | The internal name of the list | -| ↳ `webUrl` | string | The web URL of the list | -| ↳ `createdDateTime` | string | When the list was created | -| ↳ `lastModifiedDateTime` | string | When the list was last modified | -| ↳ `list` | object | List properties \(e.g., template\) | -| ↳ `columns` | array | List column definitions | -| ↳ `items` | array | List items \(with fields when expanded\) | -| ↳ `id` | string | Item ID | -| ↳ `fields` | object | Field values for the item | | `lists` | array | All lists in the site when no listId/title provided | | `items` | array | List items with expanded fields when reading list items | -| ↳ `id` | string | Item ID | -| ↳ `fields` | object | Field values for the item | | `nextPageUrl` | string | Full Microsoft Graph @odata.nextLink URL for the next page of results | @@ -380,14 +377,13 @@ Get a single item (with field values) from a SharePoint list | `siteId` | string | No | The ID of the SharePoint site \(internal use\) | | `listId` | string | Yes | The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012 | | `itemId` | string | Yes | The ID of the list item to retrieve. Example: 1, 42, or 123 | +| `selectFields` | string | No | Comma-separated column internal names to return in the item's fields. Example: Title,Status,DueDate | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `item` | object | SharePoint list item with field values | -| ↳ `id` | string | Item ID | -| ↳ `fields` | object | Field values for the item | @@ -505,6 +501,8 @@ Get metadata for a file or folder in a SharePoint document library | ↳ `file` | object | Present if the item is a file \(contains mimeType\) | | ↳ `folder` | object | Present if the item is a folder \(contains childCount\) | | ↳ `parentReference` | object | Reference to the parent folder/drive | +| ↳ `createdBy` | object | Identity set of the creator \(e.g. user.displayName, user.email\) | +| ↳ `lastModifiedBy` | object | Identity set of the last modifier \(e.g. user.displayName, user.email\) | diff --git a/apps/docs/content/docs/integrations/snowflake-service-account.mdx b/apps/docs/content/docs/integrations/snowflake-service-account.mdx index 425bdf98a12..7e32befebfa 100644 --- a/apps/docs/content/docs/integrations/snowflake-service-account.mdx +++ b/apps/docs/content/docs/integrations/snowflake-service-account.mdx @@ -1,6 +1,6 @@ --- title: Snowflake connections -description: Connect Snowflake to Sim with a keyless service user that trusts Sim through OpenID Connect, or with a programmatic access token +description: Connect Snowflake to Sim with a keyless service user that trusts Sim through OpenID Connect, a key pair, or a programmatic access token --- import { Callout } from '@/components/ui/callout' @@ -11,6 +11,7 @@ A Snowflake connection holds your account host and how Sim signs in to it: | Option | What Sim stores | When to use it | | --- | --- | --- | | **Service user (keyless, via Sim OIDC)** | Nothing secret: the account host only | Recommended where available. No token to rotate, expire, or leak. | +| **Key pair (RSA private key)** | The user's private key | Any account, including organization connections and deployments without keyless support. Nothing expires, and the key works only for the user holding its public key. | | **Programmatic access token** | A programmatic access token (PAT) | Any account, and deployments without keyless support. The token belongs to one user; use `ROLE_RESTRICTION` to limit it to a role with the access your workflows need. | Sim stores the connection as one credential. Once it is added, every Snowflake block picks it from a dropdown — and the block's database, schema, table, warehouse, role, file-format, and procedure fields become pickers that list what the token can actually see. @@ -36,7 +37,14 @@ CREATE USER SIM_WORKFLOWS TYPE = SERVICE; ``` - The audience is `snowflakecomputing.com`, Snowflake's default, so the statement needs no `OIDC_AUDIENCE_LIST`. + By default the audience is `snowflakecomputing.com`, Snowflake's default, so the statement needs no `OIDC_AUDIENCE_LIST`. That audience is shared by every Snowflake account, so Snowflake recommends one scoped to your account. To use one, enter it in the dialog's **Audience** field (for example your account URL, up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`), and the statement lists it: + +```sql + SUBJECT = '' + OIDC_AUDIENCE_LIST = ('') +``` + + Snowflake can't change `OIDC_AUDIENCE_LIST` on an existing workload identity, so to change the audience later, recreate the user's workload identity with the new value, then reconnect in Sim with the same audience. Grant the user a role with the access your workflows need and make it the default, for example `GRANT ROLE SIM_WORKFLOW_ROLE TO USER SIM_WORKFLOWS;` and `ALTER USER SIM_WORKFLOWS SET DEFAULT_ROLE = SIM_WORKFLOW_ROLE;`. If a network policy or authentication policy applies to the user, it must allow Sim's egress and workload identity. @@ -50,7 +58,7 @@ Snowflake requires each issuer and subject pair to be unique in an account, and ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject snowflake-service-account`, trust the printed subject as above, then add `host` to its `keylessConfig` and pass it to `sim credentials create snowflake-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. +Run `sim credentials keyless-subject snowflake-service-account`, trust the printed subject as above, then add `host` (and `audience`, if you listed one in `OIDC_AUDIENCE_LIST`) to its `keylessConfig` and pass it to `sim credentials create snowflake-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. ### Self-hosted deployments @@ -123,6 +131,27 @@ If an authentication policy applies to the user, `'PROGRAMMATIC_ACCESS_TOKEN'` m The host and the token are encrypted before being stored, and the token is never returned to the browser — the block sends a credential id and Sim resolves it server-side. Keyless tokens are signed on Sim's servers at run time and never stored. +## Use a key pair [#key-pair] + +Snowflake [key-pair authentication](https://docs.snowflake.com/en/user-guide/key-pair-auth) lets a user sign in with an RSA key instead of a password or token. Sim stores the private key encrypted and, on every run, signs a JWT with it that Snowflake accepts for up to an hour, sent with the `KEYPAIR_JWT` token type of the [SQL API](https://docs.snowflake.com/en/developer-guide/sql-api/authenticating). + + + + Generate an unencrypted 2048-bit key: `openssl genrsa 2048 | openssl pkcs8 -topk8 -inform PEM -out rsa_key.p8 -nocrypt`, then its public key: `openssl rsa -in rsa_key.p8 -pubout -out rsa_key.pub` + + + Assign the public key to the user (without the PEM delimiter lines): `ALTER USER SIM_WORKFLOWS SET RSA_PUBLIC_KEY = 'MIIBIjANBgkqh...';`. Grant the user a role and set it as its default role. + + + In Sim, add a Snowflake connection, choose **Key pair (RSA private key)**, and enter the **account host**, the **user**, and the contents of `rsa_key.p8` + + + Save. Sim signs a JWT and runs the same verification statement as for a token, so a wrong user, key, or host is reported before anything is saved. + + + +The JWT names the account from the host (its first label, such as `MYORG-MYACCOUNT` or the locator `XY12345` without region) and the user in uppercase. An account name with underscores works from either of its hostnames: `myorg-my_account` and `myorg-my-account` both sign in as `MYORG-MY_ACCOUNT`. To rotate, assign the new public key as `RSA_PUBLIC_KEY_2`, select **Reconnect** in Sim with the new private key, then unset the old public key. + ## Use the credential in workflows [#using-the-credential-in-workflows] Select the credential on any Snowflake block. You never enter the host again: every tool derives its endpoint from the host stored on the credential. @@ -154,10 +183,12 @@ A token's expiry is fixed at creation. To rotate, generate a new token on the sa | Keyless: "Snowflake did not accept Sim's token" | No user in this account was created with exactly the issuer and subject from the dialog, the user is not `TYPE = SERVICE`, or a network or authentication policy blocks it. Run `SHOW USER WORKLOAD IDENTITY AUTHENTICATION METHODS FOR USER SIM_WORKFLOWS` to compare. | | Keyless: error 099706 when creating the user | Another user in the account already has this issuer and subject. Find it with `SHOW USER WORKLOAD IDENTITY AUTHENTICATION METHODS` and remove it, or reconnect with a new subject. | | Keyless: runs fail for up to an hour after a signing-key rotation | Snowflake had not yet seen the new key. Publish a new key at least 60 minutes before signing with it (see [Self-hosted deployments](#self-hosted-deployments)). | -| Keyless option missing | The deployment has no workload-identity signing key, or the connection belongs to an organization rather than a workspace. Use a programmatic access token. | +| Keyless option missing | The deployment has no workload-identity signing key, or the connection belongs to an organization rather than a workspace. Use a key pair or a programmatic access token. | +| Key pair: "Snowflake did not accept a token signed with this key" | The user's `RSA_PUBLIC_KEY` (or `RSA_PUBLIC_KEY_2`) is not this key's public key, the user name is wrong, or a network or authentication policy blocks it. Compare `DESC USER SIM_WORKFLOWS` (`RSA_PUBLIC_KEY_FP`) with the key's fingerprint. | +| Key pair: the private key is refused | Sim needs an unencrypted PEM RSA private key of 2048 to 4096 bits. Convert an encrypted key with `openssl pkcs8 -in rsa_key.p8 -nocrypt -out rsa_key_unencrypted.p8`. | | Lost token secret | Snowflake shows it only at creation. Generate a replacement and reconnect the credential in Sim. | | Authentication fails | Check expiry or revocation, the user's network policy and Sim's egress, permitted authentication methods, role restriction, and SQL API access. | -| Wrong host | Use the account's Snowflake hostname. Sim reports a host error separately when the verification endpoint returns `404`. | +| Wrong host | Use the account's Snowflake hostname (`.snowflakecomputing.com`, or `.snowflakecomputing.cn` for accounts in China). Sim reports a host error separately when the verification endpoint returns `404`. | | Empty picker | Check privileges under the token's effective role. The block's execution role is not applied to metadata pickers; use advanced mode to enter a name when appropriate. | | A second account is needed | Add another credential. Each token and stored host target one Snowflake account. | | Token limit reached | Snowflake allows 15 unexpired PATs per user, including disabled tokens. Remove an unused token before creating another. | diff --git a/apps/docs/content/docs/integrations/snowflake.mdx b/apps/docs/content/docs/integrations/snowflake.mdx index dd3a2e53afe..fbbbde187e7 100644 --- a/apps/docs/content/docs/integrations/snowflake.mdx +++ b/apps/docs/content/docs/integrations/snowflake.mdx @@ -41,7 +41,7 @@ Execute one parameterized SQL statement through the Snowflake SQL API. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -92,7 +92,7 @@ Check a running or completed statement and retrieve exactly one result partition | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `statementHandle` | string | Yes | Statement handle returned by Snowflake | | `partition` | number | No | Zero-based result partition to retrieve; defaults to 0 | | `partitionCount` | number | No | Total number of result partitions, taken from the partitionCount of the first partition. Snowflake omits metadata from every later partition response, so supply this when fetching partition 1 or higher to keep truncated and nextPartition accurate | @@ -137,7 +137,7 @@ Cancel a running Snowflake SQL API statement. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `statementHandle` | string | Yes | Statement handle returned by Snowflake | #### Output @@ -180,7 +180,7 @@ Insert structured JSON rows using bound values. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -229,7 +229,7 @@ Update matching rows with a bound MERGE statement without inserting new rows. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -279,7 +279,7 @@ Update matching rows and insert unmatched rows with a bound MERGE statement. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -329,7 +329,7 @@ Delete rows matching a required set of bound column filters. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -378,7 +378,7 @@ Load files from an existing Snowflake stage with COPY INTO. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -434,7 +434,7 @@ Export a Snowflake table to files in a stage with COPY INTO. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -489,7 +489,7 @@ List the databases the credential can access. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `nameLike` | string | No | Optional SQL LIKE pattern for object names | @@ -535,7 +535,7 @@ List the schemas in a Snowflake database. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `database` | string | Yes | Database name | @@ -582,7 +582,7 @@ List the tables in a Snowflake schema. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `database` | string | Yes | Database name | @@ -630,7 +630,7 @@ List warehouses visible to the active Snowflake role. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `maxRows` | number | No | Maximum result rows; defaults to 1000 with a Sim safety limit of 10000 | @@ -676,7 +676,7 @@ Get the full details for a Snowflake virtual warehouse. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouseName` | string | Yes | Warehouse name | @@ -721,7 +721,7 @@ Resume a Snowflake virtual warehouse if it is suspended. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouseName` | string | Yes | Warehouse name | @@ -766,7 +766,7 @@ Suspend a Snowflake virtual warehouse. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouseName` | string | Yes | Warehouse name | @@ -811,7 +811,7 @@ Resize a Snowflake warehouse or change its auto-suspend and auto-resume settings | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouseName` | string | Yes | Warehouse name | @@ -859,7 +859,7 @@ List tasks in a Snowflake schema. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `database` | string | Yes | Database name | @@ -907,7 +907,7 @@ Describe a Snowflake task. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `database` | string | Yes | Database name | @@ -954,7 +954,7 @@ Run a Snowflake task immediately, optionally retrying its last failed graph. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `database` | string | Yes | Database name | @@ -1002,7 +1002,7 @@ Resume a suspended Snowflake task so its schedule runs again. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `database` | string | Yes | Database name | @@ -1049,7 +1049,7 @@ Suspend a Snowflake task so its schedule stops triggering runs. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `database` | string | Yes | Database name | @@ -1096,7 +1096,7 @@ Query up to seven days of Snowflake task history, capped at 10000 rows. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -1146,7 +1146,7 @@ Find one task history record by query ID within Snowflake’s seven-day window a | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -1195,7 +1195,7 @@ Cancel one running task query by query ID with SYSTEM$CANCEL_QUERY. Task runs al | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -1241,7 +1241,7 @@ Read a task query result with RESULT_SCAN during Snowflake’s 24-hour retention | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -1288,7 +1288,7 @@ List queries that completed in the last seven days, optionally filtered. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -1339,7 +1339,7 @@ List staged-file load results for a table over the last fourteen days. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -1390,7 +1390,7 @@ Inspect table and column metadata through Snowflake INFORMATION_SCHEMA views. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -1440,7 +1440,7 @@ Call a stored procedure with explicitly typed Snowflake bindings. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `role` | string | No | Snowflake role to use for this statement | | `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds | | `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting | @@ -1490,7 +1490,7 @@ Ask Snowflake Cortex Analyst a question in natural language against a semantic v | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `question` | string | Yes | The question to ask about your data | | `semanticView` | string | No | Fully qualified semantic view name \(for example MY_DB.MY_SCHEMA.MY_VIEW\). Provide exactly one semantic source. | | `semanticModelFile` | string | No | Stage path to a semantic model YAML file \(for example @MY_DB.MY_SCHEMA.MY_STAGE/model.yaml\). Provide exactly one semantic source. | @@ -1566,7 +1566,7 @@ Rate a Cortex Analyst answer thumbs up or down, with an optional comment. Feedba | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token or keyless sign-in\) | +| `oauthCredential` | string | Yes | Snowflake credential \(account host and programmatic access token, key pair, or keyless sign-in\) | | `requestId` | string | Yes | Request ID returned by Cortex Analyst Ask | | `positive` | boolean | Yes | true for positive \(thumbs up\) feedback, false for negative \(thumbs down\) | | `feedbackMessage` | string | No | Optional feedback comment | diff --git a/apps/docs/content/docs/integrations/splunk.mdx b/apps/docs/content/docs/integrations/splunk.mdx index dd7ca8ae4f4..db47ec885ce 100644 --- a/apps/docs/content/docs/integrations/splunk.mdx +++ b/apps/docs/content/docs/integrations/splunk.mdx @@ -2,7 +2,7 @@ title: Splunk description: Run SPL searches and manage saved searches and alerts in Splunk integration: - type: "splunk" + type: "splunk_v2" color: "#FFFFFF" --- @@ -10,9 +10,11 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} -Use [Splunk](https://www.splunk.com/) with a bearer token to search logs, metrics, and events in Splunk Enterprise or Splunk Cloud. **Run Search** returns a synchronous result; for longer searches, create a job, inspect its status, page through its results, and cancel it when needed. +Use [Splunk](https://www.splunk.com/) with an authentication token or a username and password to search logs, metrics, and events in Splunk Enterprise or Splunk Cloud. **Run Search** returns a synchronous result; for longer searches, create a job, inspect its status, page through its results, and cancel it when needed. Saved-search actions list, read, and dispatch existing searches. Alert actions inspect fired alerts and their instances. Index and app lookups help identify the data and configuration available to the credential. + +Connect Splunk once as a Splunk credential, and enter the management URL with its port, such as `https://splunk.example.com:8089`. To sign in with a token, create an authentication token in Splunk Web under **Settings > Tokens** (token authentication must be turned on) and leave the username blank. Token authentication is off by default on Splunk Enterprise, so you can instead enter a username and its password, which Sim sends with HTTP Basic authentication. Sim checks either sign-in by reading the user it authenticates as. The management URL must be reachable from Sim over https with a certificate from a public certificate authority; Sim never skips certificate verification, so a management port serving Splunk's default self-signed certificate cannot be saved. Workflows built with the earlier Splunk block keep the URL, token, or username and password they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -31,10 +33,6 @@ Run an SPL search synchronously and return its results in a single call (oneshot | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `search` | string | Yes | SPL search string \(e.g. index=main error \| stats count by host\). The leading "search" command is added automatically when omitted. | @@ -68,10 +66,6 @@ Start a Splunk search job and return its search ID (sid). The search runs asynch | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `search` | string | Yes | SPL search string \(e.g. index=main sourcetype=access_combined \| timechart count\). The leading "search" command is added automatically when omitted. | @@ -105,10 +99,6 @@ Get the status and progress of a Splunk search job by search ID, including dispa | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `sid` | string | Yes | Search ID of the job to inspect \(e.g. 1457683115.100\) | @@ -155,10 +145,6 @@ Fetch the transformed results of a completed Splunk search job by search ID, wit | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `sid` | string | Yes | Search ID of the job whose results to fetch \(e.g. 1457683115.100\) | @@ -191,10 +177,6 @@ Cancel a running Splunk search job and delete its result cache. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `sid` | string | Yes | Search ID of the job to cancel \(e.g. 1457683115.100\) | @@ -220,10 +202,6 @@ List saved searches and reports configured in Splunk, including their SPL, sched | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `search` | string | No | Filter saved searches. A bare term matches as a substring across fields \(e.g. Errors\); field_name=field_value matches one field \(e.g. is_scheduled=1\). | @@ -265,10 +243,6 @@ Get the configuration of a single Splunk saved search by name, including its SPL | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `name` | string | Yes | Name of the saved search \(e.g. Errors in the last 24 hours\) | @@ -305,10 +279,6 @@ Run a Splunk saved search immediately and return the search ID (sid) of the disp | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `name` | string | Yes | Name of the saved search to run \(e.g. Errors in the last 24 hours\) | @@ -338,10 +308,6 @@ List the saved searches with currently triggered (unexpired) Splunk alerts and h | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `count` | number | No | Maximum number of entries to return \(e.g. 50\). 0 returns all. | @@ -371,10 +337,6 @@ List the unexpired triggered instances of a Splunk alert by saved search name, i | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `name` | string | Yes | Name of the alerting saved search \(e.g. Errors in the last 24 hours\). Use - to return the fired alerts of every saved search — this endpoint documents "Request parameters: None", so there is no count or offset to bound that with. Name one saved search unless you really want all of them. | @@ -409,10 +371,6 @@ List the indexes configured on the Splunk instance with their size, event count, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `datatype` | string | No | Filter indexes by type: all, event, or metric. Splunk defaults to event, so pass all to include metric indexes. | @@ -454,10 +412,6 @@ List the apps installed on the Splunk instance with their label, version, author | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `baseUrl` | string | Yes | Splunk management URL including the management port \(e.g. https://splunk.example.com:8089) | -| `authToken` | string | No | Splunk authentication token, sent as a bearer token. Preferred over a password. | -| `username` | string | No | Splunk username, used for basic authentication when no token is supplied | -| `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `count` | number | No | Maximum number of apps to return \(e.g. 50\). 0 returns all. | diff --git a/apps/docs/content/docs/integrations/square.mdx b/apps/docs/content/docs/integrations/square.mdx index 8c74f46f439..9b18a7dc50b 100644 --- a/apps/docs/content/docs/integrations/square.mdx +++ b/apps/docs/content/docs/integrations/square.mdx @@ -12,7 +12,7 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Square](https://squareup.com/) to take, retrieve, list, cancel, complete, or refund payments; manage customer profiles; create, retrieve, search, or pay for orders; and create, publish, cancel, or delete invoices. -The actions also manage catalog objects and images, read inventory counts, and look up locations. Connect with a saved Square credential holding a production access token. Payment and refund actions change live commerce records; use the action's idempotency fields before running them. +The actions also manage catalog objects and images, read inventory counts, and look up locations. Connect with a saved Square credential holding a production or sandbox access token, and choose the environment that issued it; a sandbox credential runs every action against the Square sandbox. Payment and refund actions on a production credential change live commerce records; use the action's idempotency fields before running them. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/docs/content/docs/integrations/sts.mdx b/apps/docs/content/docs/integrations/sts.mdx index d758e051a3f..20a2fb0f9ba 100644 --- a/apps/docs/content/docs/integrations/sts.mdx +++ b/apps/docs/content/docs/integrations/sts.mdx @@ -40,6 +40,7 @@ Assume an IAM role and receive temporary security credentials | `policyArns` | string | No | Comma-separated ARNs of up to 10 IAM managed policies to use as session policies | | `tags` | string | No | JSON object of up to 50 session tag key/value pairs for attribute-based access control | | `transitiveTagKeys` | string | No | Comma-separated tag keys that propagate through role chaining | +| `sourceIdentity` | string | No | Source identity to set on the role session \(2-64 characters; cannot start with aws:\) | #### Output diff --git a/apps/docs/content/docs/integrations/textract.mdx b/apps/docs/content/docs/integrations/textract.mdx index c2fce26bce1..2bc37fd068b 100644 --- a/apps/docs/content/docs/integrations/textract.mdx +++ b/apps/docs/content/docs/integrations/textract.mdx @@ -74,6 +74,11 @@ Parse documents using AWS Textract OCR and document analysis | `documentMetadata` | object | Metadata about the analyzed document | | ↳ `pages` | number | Number of pages in the document | | `modelVersion` | string | Version of the Textract model used for processing | +| `jobStatus` | string | Async job status: SUCCEEDED or PARTIAL_SUCCESS \(multi-page mode only\) | +| `statusMessage` | string | Why an async job only partially succeeded \(multi-page mode only\) | +| `warnings` | array | Async job warnings for pages Textract could not fully process | +| ↳ `errorCode` | string | Warning error code | +| ↳ `pages` | array | Page numbers the warning applies to | @@ -107,6 +112,11 @@ Extract structured invoice and receipt fields using AWS Textract AnalyzeExpense | `documentMetadata` | object | Metadata about the analyzed document | | ↳ `pages` | number | Number of pages in the document | | `modelVersion` | string | Version of the AnalyzeExpense model used \(multi-page/async only\) | +| `jobStatus` | string | Async job status: SUCCEEDED or PARTIAL_SUCCESS \(multi-page mode only\) | +| `statusMessage` | string | Why an async job only partially succeeded \(multi-page mode only\) | +| `warnings` | array | Async job warnings for pages Textract could not fully process | +| ↳ `errorCode` | string | Warning error code | +| ↳ `pages` | array | Page numbers the warning applies to | diff --git a/apps/docs/content/docs/integrations/trello.mdx b/apps/docs/content/docs/integrations/trello.mdx index 443251cf93c..293ece5bd8e 100644 --- a/apps/docs/content/docs/integrations/trello.mdx +++ b/apps/docs/content/docs/integrations/trello.mdx @@ -83,6 +83,8 @@ List cards from a Trello board or list | `boardId` | string | No | Trello board ID to list open cards from. Provide either boardId or listId | | `listId` | string | No | Trello list ID to list cards from. Provide either boardId or listId | | `filter` | string | No | Which cards to return: open, closed, or all \(defaults to open\) | +| `since` | string | No | Only return cards created since this date \(ISO 8601\) or card ID | +| `before` | string | No | Only return cards created before this date \(ISO 8601\) or card ID | #### Output @@ -103,6 +105,54 @@ List cards from a Trello board or list | ↳ `color` | string | Label color | | ↳ `due` | string | Card due date in ISO 8601 format | | ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `start` | string | Card start date in ISO 8601 format | +| ↳ `pos` | number | Position of the card in its list | +| ↳ `shortUrl` | string | Short card URL without the name slug | +| ↳ `shortLink` | string | 8-character short card ID | +| ↳ `idShort` | number | Board-specific numeric card ID \(can change when the card moves\) | +| ↳ `idMembers` | array | Member IDs assigned to the card | +| ↳ `idChecklists` | array | Checklist IDs on the card | +| ↳ `dateLastActivity` | string | When the card last had activity \(ISO 8601\) | +| ↳ `badges` | object | Card badge counts shown on the front of the card | +| ↳ `votes` | number | Vote count | +| ↳ `comments` | number | Comment count | +| ↳ `attachments` | number | Attachment count | +| ↳ `checkItems` | number | Total checklist items | +| ↳ `checkItemsChecked` | number | Completed checklist items | +| ↳ `description` | boolean | Whether the card has a description | +| ↳ `subscribed` | boolean | Whether the current member is subscribed | +| ↳ `location` | boolean | Whether the card has a location | +| ↳ `due` | string | Due date | +| ↳ `start` | string | Start date | +| ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `checklists` | array | Checklists with their items. Only returned when checklists are requested | +| ↳ `id` | string | Checklist ID | +| ↳ `name` | string | Checklist name | +| ↳ `idCard` | string | Card ID | +| ↳ `idBoard` | string | Board ID | +| ↳ `pos` | number | Checklist position | +| ↳ `checkItems` | array | Checklist items | +| ↳ `id` | string | Checklist item ID | +| ↳ `name` | string | Checklist item name | +| ↳ `state` | string | complete or incomplete | +| ↳ `pos` | number | Item position | +| ↳ `idChecklist` | string | Checklist ID | +| ↳ `due` | string | Item due date | +| ↳ `idMember` | string | Assigned member ID | +| ↳ `attachments` | array | Card attachments. Only returned when attachments are requested | +| ↳ `id` | string | Attachment ID | +| ↳ `name` | string | Attachment name | +| ↳ `url` | string | Attachment URL | +| ↳ `mimeType` | string | Attachment MIME type | +| ↳ `bytes` | number | File size in bytes | +| ↳ `date` | string | When the attachment was added | +| ↳ `idMember` | string | ID of the member who added it | +| ↳ `isUpload` | boolean | Whether the attachment was uploaded | +| ↳ `pos` | number | Attachment position | +| ↳ `members` | array | Member objects on the card. Only returned when members are requested | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | | `count` | number | Number of cards returned | @@ -121,6 +171,9 @@ Search Trello cards and boards by keyword | `idBoards` | array | No | Restrict the search to these board IDs | | `modelTypes` | string | No | Comma-separated result types to search: cards, boards, or all \(default all\) | | `cardsLimit` | number | No | Maximum number of cards to return \(1-1000, default 10\) | +| `cardsPage` | number | No | Page of card results to return \(0-100, default 0\) | +| `boardsLimit` | number | No | Maximum number of boards to return \(1-1000, default 10\) | +| `partial` | boolean | No | Match the start of words as well as whole words \(default false\) | #### Output @@ -134,6 +187,61 @@ Search Trello cards and boards by keyword | ↳ `idBoard` | string | Board ID containing the card | | ↳ `idList` | string | List ID containing the card | | ↳ `closed` | boolean | Whether the card is archived | +| ↳ `labelIds` | array | Label IDs applied to the card | +| ↳ `labels` | array | Labels applied to the card | +| ↳ `id` | string | Label ID | +| ↳ `name` | string | Label name | +| ↳ `color` | string | Label color | +| ↳ `due` | string | Card due date in ISO 8601 format | +| ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `start` | string | Card start date in ISO 8601 format | +| ↳ `pos` | number | Position of the card in its list | +| ↳ `shortUrl` | string | Short card URL without the name slug | +| ↳ `shortLink` | string | 8-character short card ID | +| ↳ `idShort` | number | Board-specific numeric card ID \(can change when the card moves\) | +| ↳ `idMembers` | array | Member IDs assigned to the card | +| ↳ `idChecklists` | array | Checklist IDs on the card | +| ↳ `dateLastActivity` | string | When the card last had activity \(ISO 8601\) | +| ↳ `badges` | object | Card badge counts shown on the front of the card | +| ↳ `votes` | number | Vote count | +| ↳ `comments` | number | Comment count | +| ↳ `attachments` | number | Attachment count | +| ↳ `checkItems` | number | Total checklist items | +| ↳ `checkItemsChecked` | number | Completed checklist items | +| ↳ `description` | boolean | Whether the card has a description | +| ↳ `subscribed` | boolean | Whether the current member is subscribed | +| ↳ `location` | boolean | Whether the card has a location | +| ↳ `due` | string | Due date | +| ↳ `start` | string | Start date | +| ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `checklists` | array | Checklists with their items. Only returned when checklists are requested | +| ↳ `id` | string | Checklist ID | +| ↳ `name` | string | Checklist name | +| ↳ `idCard` | string | Card ID | +| ↳ `idBoard` | string | Board ID | +| ↳ `pos` | number | Checklist position | +| ↳ `checkItems` | array | Checklist items | +| ↳ `id` | string | Checklist item ID | +| ↳ `name` | string | Checklist item name | +| ↳ `state` | string | complete or incomplete | +| ↳ `pos` | number | Item position | +| ↳ `idChecklist` | string | Checklist ID | +| ↳ `due` | string | Item due date | +| ↳ `idMember` | string | Assigned member ID | +| ↳ `attachments` | array | Card attachments. Only returned when attachments are requested | +| ↳ `id` | string | Attachment ID | +| ↳ `name` | string | Attachment name | +| ↳ `url` | string | Attachment URL | +| ↳ `mimeType` | string | Attachment MIME type | +| ↳ `bytes` | number | File size in bytes | +| ↳ `date` | string | When the attachment was added | +| ↳ `idMember` | string | ID of the member who added it | +| ↳ `isUpload` | boolean | Whether the attachment was uploaded | +| ↳ `pos` | number | Attachment position | +| ↳ `members` | array | Member objects on the card. Only returned when members are requested | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | | `boards` | array | Boards matching the search query | | ↳ `id` | string | Board ID | | ↳ `name` | string | Board name | @@ -160,6 +268,7 @@ Create a new card in a Trello list | `desc` | string | No | Description of the card | | `pos` | string | No | Position of the card \(top, bottom, or positive float\) | | `due` | string | No | Due date \(ISO 8601 format\) | +| `start` | string | No | Start date \(ISO 8601 format\) | | `dueComplete` | boolean | No | Whether the due date should be marked complete | | `labelIds` | array | No | Label IDs to attach to the card | | `memberIds` | array | No | Member IDs to assign to the card | @@ -168,7 +277,7 @@ Create a new card in a Trello list | Parameter | Type | Description | | --------- | ---- | ----------- | -| `card` | json | Created card \(id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete\) | +| `card` | json | Created card \(id, name, desc, url, shortUrl, shortLink, idShort, idBoard, idList, closed, pos, labelIds, labels, idMembers, idChecklists, due, start, dueComplete, dateLastActivity, badges, checklists, attachments, members\) | | ↳ `id` | string | Card ID | | ↳ `name` | string | Card name | | ↳ `desc` | string | Card description | @@ -183,6 +292,54 @@ Create a new card in a Trello list | ↳ `color` | string | Label color | | ↳ `due` | string | Card due date in ISO 8601 format | | ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `start` | string | Card start date in ISO 8601 format | +| ↳ `pos` | number | Position of the card in its list | +| ↳ `shortUrl` | string | Short card URL without the name slug | +| ↳ `shortLink` | string | 8-character short card ID | +| ↳ `idShort` | number | Board-specific numeric card ID \(can change when the card moves\) | +| ↳ `idMembers` | array | Member IDs assigned to the card | +| ↳ `idChecklists` | array | Checklist IDs on the card | +| ↳ `dateLastActivity` | string | When the card last had activity \(ISO 8601\) | +| ↳ `badges` | object | Card badge counts shown on the front of the card | +| ↳ `votes` | number | Vote count | +| ↳ `comments` | number | Comment count | +| ↳ `attachments` | number | Attachment count | +| ↳ `checkItems` | number | Total checklist items | +| ↳ `checkItemsChecked` | number | Completed checklist items | +| ↳ `description` | boolean | Whether the card has a description | +| ↳ `subscribed` | boolean | Whether the current member is subscribed | +| ↳ `location` | boolean | Whether the card has a location | +| ↳ `due` | string | Due date | +| ↳ `start` | string | Start date | +| ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `checklists` | array | Checklists with their items. Only returned when checklists are requested | +| ↳ `id` | string | Checklist ID | +| ↳ `name` | string | Checklist name | +| ↳ `idCard` | string | Card ID | +| ↳ `idBoard` | string | Board ID | +| ↳ `pos` | number | Checklist position | +| ↳ `checkItems` | array | Checklist items | +| ↳ `id` | string | Checklist item ID | +| ↳ `name` | string | Checklist item name | +| ↳ `state` | string | complete or incomplete | +| ↳ `pos` | number | Item position | +| ↳ `idChecklist` | string | Checklist ID | +| ↳ `due` | string | Item due date | +| ↳ `idMember` | string | Assigned member ID | +| ↳ `attachments` | array | Card attachments. Only returned when attachments are requested | +| ↳ `id` | string | Attachment ID | +| ↳ `name` | string | Attachment name | +| ↳ `url` | string | Attachment URL | +| ↳ `mimeType` | string | Attachment MIME type | +| ↳ `bytes` | number | File size in bytes | +| ↳ `date` | string | When the attachment was added | +| ↳ `idMember` | string | ID of the member who added it | +| ↳ `isUpload` | boolean | Whether the attachment was uploaded | +| ↳ `pos` | number | Attachment position | +| ↳ `members` | array | Member objects on the card. Only returned when members are requested | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | @@ -203,12 +360,17 @@ Update an existing card on Trello | `idList` | string | No | Trello list ID to move card to \(24-character hex string\) | | `due` | string | No | Due date \(ISO 8601 format\) | | `dueComplete` | boolean | No | Mark the due date as complete | +| `start` | string | No | Start date \(ISO 8601 format\) | +| `pos` | string | No | Position of the card in its list \(top, bottom, or positive float\) | +| `idBoard` | string | No | Trello board ID to move the card to \(use with idList\) | +| `labelIds` | array | No | Replace the card labels with these label IDs \(array or comma-separated string\). An empty list leaves labels unchanged; use Remove Label to take one off | +| `memberIds` | array | No | Replace the card members with these member IDs \(array or comma-separated string\). An empty list leaves members unchanged; use Remove Member to take one off | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `card` | json | Updated card \(id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete\) | +| `card` | json | Updated card \(id, name, desc, url, shortUrl, shortLink, idShort, idBoard, idList, closed, pos, labelIds, labels, idMembers, idChecklists, due, start, dueComplete, dateLastActivity, badges, checklists, attachments, members\) | | ↳ `id` | string | Card ID | | ↳ `name` | string | Card name | | ↳ `desc` | string | Card description | @@ -223,6 +385,54 @@ Update an existing card on Trello | ↳ `color` | string | Label color | | ↳ `due` | string | Card due date in ISO 8601 format | | ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `start` | string | Card start date in ISO 8601 format | +| ↳ `pos` | number | Position of the card in its list | +| ↳ `shortUrl` | string | Short card URL without the name slug | +| ↳ `shortLink` | string | 8-character short card ID | +| ↳ `idShort` | number | Board-specific numeric card ID \(can change when the card moves\) | +| ↳ `idMembers` | array | Member IDs assigned to the card | +| ↳ `idChecklists` | array | Checklist IDs on the card | +| ↳ `dateLastActivity` | string | When the card last had activity \(ISO 8601\) | +| ↳ `badges` | object | Card badge counts shown on the front of the card | +| ↳ `votes` | number | Vote count | +| ↳ `comments` | number | Comment count | +| ↳ `attachments` | number | Attachment count | +| ↳ `checkItems` | number | Total checklist items | +| ↳ `checkItemsChecked` | number | Completed checklist items | +| ↳ `description` | boolean | Whether the card has a description | +| ↳ `subscribed` | boolean | Whether the current member is subscribed | +| ↳ `location` | boolean | Whether the card has a location | +| ↳ `due` | string | Due date | +| ↳ `start` | string | Start date | +| ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `checklists` | array | Checklists with their items. Only returned when checklists are requested | +| ↳ `id` | string | Checklist ID | +| ↳ `name` | string | Checklist name | +| ↳ `idCard` | string | Card ID | +| ↳ `idBoard` | string | Board ID | +| ↳ `pos` | number | Checklist position | +| ↳ `checkItems` | array | Checklist items | +| ↳ `id` | string | Checklist item ID | +| ↳ `name` | string | Checklist item name | +| ↳ `state` | string | complete or incomplete | +| ↳ `pos` | number | Item position | +| ↳ `idChecklist` | string | Checklist ID | +| ↳ `due` | string | Item due date | +| ↳ `idMember` | string | Assigned member ID | +| ↳ `attachments` | array | Card attachments. Only returned when attachments are requested | +| ↳ `id` | string | Attachment ID | +| ↳ `name` | string | Attachment name | +| ↳ `url` | string | Attachment URL | +| ↳ `mimeType` | string | Attachment MIME type | +| ↳ `bytes` | number | File size in bytes | +| ↳ `date` | string | When the attachment was added | +| ↳ `idMember` | string | ID of the member who added it | +| ↳ `isUpload` | boolean | Whether the attachment was uploaded | +| ↳ `pos` | number | Attachment position | +| ↳ `members` | array | Member objects on the card. Only returned when members are requested | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | @@ -460,12 +670,15 @@ Retrieve a single Trello card by ID | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `cardId` | string | Yes | Trello card ID \(24-character hex string\) | +| `includeChecklists` | boolean | No | Include each card checklist with its items \(default false\) | +| `includeAttachments` | boolean | No | Include card attachments \(default false\) | +| `includeMembers` | boolean | No | Include member objects for the members on the card \(default false\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `card` | json | Card \(id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete\) | +| `card` | json | Card \(id, name, desc, url, shortUrl, shortLink, idShort, idBoard, idList, closed, pos, labelIds, labels, idMembers, idChecklists, due, start, dueComplete, dateLastActivity, badges, checklists, attachments, members\) | | ↳ `id` | string | Card ID | | ↳ `name` | string | Card name | | ↳ `desc` | string | Card description | @@ -480,6 +693,54 @@ Retrieve a single Trello card by ID | ↳ `color` | string | Label color | | ↳ `due` | string | Card due date in ISO 8601 format | | ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `start` | string | Card start date in ISO 8601 format | +| ↳ `pos` | number | Position of the card in its list | +| ↳ `shortUrl` | string | Short card URL without the name slug | +| ↳ `shortLink` | string | 8-character short card ID | +| ↳ `idShort` | number | Board-specific numeric card ID \(can change when the card moves\) | +| ↳ `idMembers` | array | Member IDs assigned to the card | +| ↳ `idChecklists` | array | Checklist IDs on the card | +| ↳ `dateLastActivity` | string | When the card last had activity \(ISO 8601\) | +| ↳ `badges` | object | Card badge counts shown on the front of the card | +| ↳ `votes` | number | Vote count | +| ↳ `comments` | number | Comment count | +| ↳ `attachments` | number | Attachment count | +| ↳ `checkItems` | number | Total checklist items | +| ↳ `checkItemsChecked` | number | Completed checklist items | +| ↳ `description` | boolean | Whether the card has a description | +| ↳ `subscribed` | boolean | Whether the current member is subscribed | +| ↳ `location` | boolean | Whether the card has a location | +| ↳ `due` | string | Due date | +| ↳ `start` | string | Start date | +| ↳ `dueComplete` | boolean | Whether the due date is complete | +| ↳ `checklists` | array | Checklists with their items. Only returned when checklists are requested | +| ↳ `id` | string | Checklist ID | +| ↳ `name` | string | Checklist name | +| ↳ `idCard` | string | Card ID | +| ↳ `idBoard` | string | Board ID | +| ↳ `pos` | number | Checklist position | +| ↳ `checkItems` | array | Checklist items | +| ↳ `id` | string | Checklist item ID | +| ↳ `name` | string | Checklist item name | +| ↳ `state` | string | complete or incomplete | +| ↳ `pos` | number | Item position | +| ↳ `idChecklist` | string | Checklist ID | +| ↳ `due` | string | Item due date | +| ↳ `idMember` | string | Assigned member ID | +| ↳ `attachments` | array | Card attachments. Only returned when attachments are requested | +| ↳ `id` | string | Attachment ID | +| ↳ `name` | string | Attachment name | +| ↳ `url` | string | Attachment URL | +| ↳ `mimeType` | string | Attachment MIME type | +| ↳ `bytes` | number | File size in bytes | +| ↳ `date` | string | When the attachment was added | +| ↳ `idMember` | string | ID of the member who added it | +| ↳ `isUpload` | boolean | Whether the attachment was uploaded | +| ↳ `pos` | number | Attachment position | +| ↳ `members` | array | Member objects on the card. Only returned when members are requested | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index 35fcecf501c..8c98e6ced2e 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -46,7 +46,7 @@ API-key and personal-access-token connections use the credential shown in their | Azure DevOps | PAT with Project and Team Read, Wiki Read, Work Items Read, and Code Read. | | GitLab | PAT with `read_api` and access to the selected project; permission-aware methods have additional requirements. | | YouTube | YouTube Data API key from Google Cloud Console. | -| Amazon S3 | Secret Access Key; enter Access Key ID, region, and bucket as configuration fields. | +| Amazon S3 | Secret Access Key; enter Access Key ID, region, and bucket as configuration fields. Where enabled, **Authentication → Saved AWS credential** uses a workspace AWS credential instead (access keys, an assumed IAM role, or a keyless role trusted through Sim OIDC); it signs only for Amazon S3 in a commercial or GovCloud AWS region (not China or isolated regions), so it has no Access Key ID or custom endpoint. Either way, the identity needs `s3:ListBucket` on the bucket and `s3:GetObject` on the synced objects. | | Sentry | Auth token with `project:read` and `event:read`. | | PagerDuty | Create a [REST API key](https://support.pagerduty.com/main/docs/api-access-keys) under **Integrations → Developer Tools → API Access Keys**. | | SFTP | Password or unencrypted private key, plus a required SHA-256 host-key fingerprint; enter host, port, username, and root path as configuration fields. | diff --git a/apps/docs/content/docs/platform/enterprise/access-control.mdx b/apps/docs/content/docs/platform/enterprise/access-control.mdx index 8f87c9cf670..3b3b27c4e3f 100644 --- a/apps/docs/content/docs/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/platform/enterprise/access-control.mdx @@ -95,6 +95,7 @@ Most platform restrictions refuse the protected operation at the server as well | Tools | Custom Tools | Prevents agents from calling custom tools. | | Tools | Skills | Prevents agents from loading skills. | | Tools | Tool Auto-Approval | Prevents storing an always-allow preference for tool calls. | +| Tools | Computer Use | Prevents Chat from operating apps on a member's Mac, even when the device allows it. | | Logs | Trace Spans | Withholds per-block spans from log reads and API responses. | | Logs | Log Export | Prevents CSV execution-log downloads. | | Logs | Execution Cost | Omits cost and token spend from governed logs and member exports. Organization-admin data drains are not projected this way. | diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx index e4b235cf1cf..0d125a545a7 100644 --- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx @@ -393,6 +393,7 @@ GitHub workflow blocks and knowledge-base token connections continue to use pers | Help Scout | `HELPSCOUT_CLIENT_ID` / `HELPSCOUT_CLIENT_SECRET` | `helpscout` | | HubSpot | `HUBSPOT_CLIENT_ID` / `HUBSPOT_CLIENT_SECRET` | `hubspot` | | Salesforce | `SALESFORCE_CLIENT_ID` / `SALESFORCE_CLIENT_SECRET` | `salesforce` | +| Apollo | `APOLLO_CLIENT_ID` / `APOLLO_CLIENT_SECRET` | `apollo` | | Pipedrive | `PIPEDRIVE_CLIENT_ID` / `PIPEDRIVE_CLIENT_SECRET` | `pipedrive` | | Attio | `ATTIO_CLIENT_ID` / `ATTIO_CLIENT_SECRET` | `attio` | | Zoho Desk | `ZOHO_CLIENT_ID` / `ZOHO_CLIENT_SECRET` | `zoho-desk` | diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx index 7ed99eb6c4b..283d6c297e8 100644 --- a/apps/docs/content/docs/search/gitlab.mdx +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -73,6 +73,37 @@ In this example, Alice can search `engineering/platform` because the files link Saving the CSVs grants the access listed in them. Changing a membership in GitLab alone does not change these grants. Replace the CSVs to add or revoke access; the change applies to the next search or read without re-indexing. +### Export both CSVs from GitLab + +Download [export-permissions.py](/static/examples/gitlab/export-permissions.py) and save it as `ci/export-gitlab-permissions.py` in your repository. It uses Python 3.10 or later with no extra packages and exports one project per run. + +Use GitLab 17.4 or later and an internal export token with `read_api` and permission to see all members of the target project, their primary emails, and confirmation status. Keep this token in a masked, protected CI variable named `GITLAB_EXPORT_TOKEN`. It stays in your CI job; Sim receives the CSVs. The source's separate read-only PAT continues to retrieve content. + +The exporter makes only these GitLab API requests: + +| Endpoint | Purpose | +| --- | --- | +| `GET /api/v4/projects/:id` | Resolve the numeric ID and full project path. | +| `GET /api/v4/projects/:id/members/all` | Page through visible direct, inherited, and invited-group members, 100 at a time. | +| `GET /api/v4/users/:id` | Read each eligible member's account state, email confirmation, and primary email. | + +GitLab documents [membership visibility](https://docs.gitlab.com/api/project_members/#list-all-members-of-a-project) and [user profile fields](https://docs.gitlab.com/api/users/#retrieve-a-single-user). The `read_api` scope alone does not grant access to private emails. The export token needs that directory permission too. Profiles without a valid `confirmed_at` timestamp are excluded. If an eligible reader's primary email is unavailable, the script stops; it never substitutes a public email. Confirm the exported emails match your teammates' verified Sim emails. + +With `GITLAB_EXPORT_TOKEN` set in the environment, run: + +```bash +python3 ci/export-gitlab-permissions.py \ + --gitlab-url https://gitlab.example.com \ + --project engineering/platform \ + --output-dir gitlab-permissions +``` + +This creates `gitlab-permissions/users.csv` and `gitlab-permissions/project-permissions.csv`. Numeric project IDs are also accepted. The output directory must not already exist. Both files are published together after the export completes and passes validation. + +The export includes active, unlocked human members with Reporter access or higher. It excludes Guests, Planners, bots, and memberships expiring today or earlier in UTC. It does not infer access from public/internal visibility, administrator or Auditor privileges, or custom permissions below Reporter. GitLab policies such as SSO or external authorization are not represented by these CSVs. Review the grants against your organization's access requirements before uploading them. + +Requests run serially, paced at 60 per minute by default. Set `--requests-per-minute` to change this export-job ceiling; it does not configure live Search traffic. Each run uses one project request, one request per membership page, and one profile request per candidate reader. API errors, hidden emails, identity conflicts, inconsistent pagination totals (when GitLab provides them), and oversized exports stop the job without publishing either file. Run exports after bulk membership updates finish; paginated API reads are not a point-in-time snapshot. + ## Access checks The source token retrieves content. Administrator mode proves the reader's current GitLab identity from verified Sim email, then checks project/group membership and policy during the request. Unsupported restrictions fail closed. @@ -93,7 +124,7 @@ After an admin configures a source in **Non-admin token** mode, a job can [read Store an organization admin's [personal Sim API key](/api-reference/authentication) with [**Full access**](/api-reference/authentication#key-access) as a [masked, protected GitLab CI variable](https://docs.gitlab.com/ci/variables/) named `SIM_API_KEY`. A personal key inherits its owner's access; a read-only key can read the revision but not replace the CSVs. This endpoint can only read the revision or replace both CSVs; it cannot change the GitLab PAT, host, or project. -This example runs on a schedule against a protected branch. Replace `./ci/export-gitlab-permissions` with your existing export command. It must write a fresh, complete `users.csv` and `project-permissions.csv` and exit nonzero if it cannot finish. +Commit [the exporter above](#export-both-csvs-from-gitlab) at `ci/export-gitlab-permissions.py`. Set `GITLAB_EXPORT_TOKEN`, `GITLAB_PROJECT` (full path or numeric ID), and the Sim variables above. This example uses `CI_SERVER_URL` for the GitLab host and runs on a schedule against a protected branch. Choose the schedule based on how quickly permission changes must reach Sim. ```yaml title=".gitlab-ci.yml" sync_gitlab_permissions: @@ -102,22 +133,28 @@ sync_gitlab_permissions: rules: - if: '$CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_REF_PROTECTED == "true"' before_script: - - apk add --no-cache curl jq + - apk add --no-cache ca-certificates curl jq python3 script: - | set -eu + umask 077 + export_root=$(mktemp -d) + trap 'rm -rf "$export_root"' EXIT url="${SIM_BASE_URL%/}/api/v2/organizations/$SIM_ORG_ID/search/sources/$SIM_SOURCE_ID/permissions" auth="X-API-Key: $SIM_API_KEY" current=$(curl --fail --silent --show-error -H "$auth" "$url") revision=$(printf '%s' "$current" | jq -er '.data.revision') - ./ci/export-gitlab-permissions + python3 ci/export-gitlab-permissions.py \ + --gitlab-url "$CI_SERVER_URL" --project "$GITLAB_PROJECT" \ + --output-dir "$export_root/permissions" --requests-per-minute 60 jq -n --argjson rev "$revision" \ - --rawfile users users.csv --rawfile grants project-permissions.csv \ + --rawfile users "$export_root/permissions/users.csv" \ + --rawfile grants "$export_root/permissions/project-permissions.csv" \ '{expectedRevision: $rev, permissions: {provider: "gitlab", userMapping: {filename: "users.csv", content: $users}, - projectPermissions: {filename: "project-permissions.csv", content: $grants}}}' > update.json + projectPermissions: {filename: "project-permissions.csv", content: $grants}}}' > "$export_root/update.json" curl --fail --silent --show-error --request PUT -H "$auth" \ - -H 'Content-Type: application/json' --data-binary @update.json "$url" + -H 'Content-Type: application/json' --data-binary "@$export_root/update.json" "$url" ``` A successful update returns the new revision. A `409 Conflict` means someone updated the source after the job read its revision. Rerun the job from the beginning so it exports current grants. An invalid or header-only export leaves the previous grants in place. Alert on failed jobs so revocations aren't missed. If the project should have no readers, choose **Pause search** in the source settings. You can update the grants while the source is paused, then resume search when the grants are correct. diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index 966f822339c..10b3c26df9b 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -36,6 +36,8 @@ The model that runs the step. Defaults to `claude-sonnet-5-5`. Type or pick any For a custom cloud deployment, enter its provider prefix and model ID: `azure/my-deployment`, `azure-anthropic/my-deployment`, `bedrock/my-inference-profile`, or `vertex/my-gemini-model`. The prefix selects the provider and shows its credential fields even when the ID is absent from the catalog. Bedrock accepts full inference profile ARNs after `bedrock/`; Vertex uses the Gemini API and accepts Google model resource names. The deployment must support the selected provider's API. Custom IDs have no catalog pricing or token limits. +For an AWS Bedrock model, set **AWS Authentication** to **Access keys** to type a key pair into the block, or to **Saved AWS credential** to sign with a workspace [AWS credential](/integrations/aws-credentials) (access keys, an assumed IAM role, or a keyless role). A saved credential is used on its own: if it cannot be used, the run fails rather than signing with other keys. Its identity needs `bedrock:InvokeModel`, plus `bedrock:InvokeModelWithResponseStream` for streamed runs. Sim calls catalog Claude models through a geographic inference profile (`us.`, `eu.`, or `au.`, picked from the block's **AWS Region**), so a policy scoped to specific resources must allow the inference profile and the foundation model in every Region that profile routes to, as AWS's [inference profile prerequisites](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-prereq.html) describe. An assumed or keyless role's session starts when the block runs and is not renewed during the run, so one run must finish within the credential's session duration. The Router and Evaluator blocks offer the same choice. + Ollama Cloud, OpenRouter, Fireworks, Together AI, Baseten, Ollama, vLLM, and LiteLLM load their available models from the configured provider. New models appear through that discovery without a Sim catalog release. You can also enter a namespaced ID directly, such as `ollama-cloud/deepseek-v4.1-flash`, `openrouter/provider/model`, or `ollama/my-local-model`. Provider prefixes are case-insensitive; the model ID after the prefix keeps its original casing. #### Nebius Token Factory diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index ae3f904261c..b490259f54c 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -6933,7 +6933,7 @@ "type": "null" } ], - "description": "OAuth credential identifier, or null for API-key and unauthenticated sources." + "description": "OAuth credential identifier, or the saved credential an `apiKey` source syncs with (see `auth.savedCredential`); null for unauthenticated sources and for `apiKey` sources that use an inline API key." }, "sourceConfig": { "type": "object", @@ -7155,7 +7155,7 @@ "description": "Registered connector type." }, "credentialId": { - "description": "OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types); omit it for `apiKey` connectors.", + "description": "OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.", "type": "string", "minLength": 1, "maxLength": 255 @@ -7331,7 +7331,7 @@ "type": "null" } ], - "description": "OAuth credential identifier, or null for API-key and unauthenticated sources." + "description": "OAuth credential identifier, or the saved credential an `apiKey` source syncs with (see `auth.savedCredential`); null for unauthenticated sources and for `apiKey` sources that use an inline API key." }, "sourceConfig": { "type": "object", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 86126938320..5077105d00a 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -4382,7 +4382,7 @@ "post": { "operationId": "createCredentialWorkloadIdentitySubject", "summary": "Create workload identity subject", - "description": "Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus `keylessConfig`: a prefilled `serviceAccountJson` whose `subjectProof` is valid for 24 hours and only for the requesting user. Add the `requiredConfigFields`, then call Create service-account credential with the same `providerId`; Sim verifies the trust while saving. Google Cloud has no fixed audience: it is the credential's own pool provider. Without keyless support the deployment returns `400` with `details.code` `WORKLOAD_IDENTITY_NOT_CONFIGURED`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus `keylessConfig`: a prefilled `serviceAccountJson` whose `subjectProof` is valid for 24 hours and only for the requesting user. Add the `requiredConfigFields`, then call Create service-account credential with the same `providerId`; Sim verifies the trust while saving. The returned `audience` is the default; set `audience` in `keylessConfig` to have Sim sign tokens for your own value instead, and trust that value in the cloud. Google Cloud has no fixed default: it is the credential's own pool provider. Without keyless support the deployment returns `400` with `details.code` `WORKLOAD_IDENTITY_NOT_CONFIGURED`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "credentials.workload_identity.prepare", "x-oauth-scope": "api:write", "tags": ["Credentials"], @@ -4449,7 +4449,7 @@ "subjectProof": "" }, "requiredConfigFields": ["roleArn"], - "optionalConfigFields": ["stsRegion", "sessionDurationSeconds"] + "optionalConfigFields": ["stsRegion", "sessionDurationSeconds", "audience"] } } } @@ -7431,7 +7431,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -7592,7 +7593,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -7753,7 +7755,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -7922,7 +7925,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -17950,6 +17954,12 @@ "additionalProperties": false } }, + "defaultValue": { + "description": "Value an optional field takes when it is omitted.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, "hint": { "description": "Provider-specific setup guidance.", "type": "string", @@ -18106,6 +18116,12 @@ "additionalProperties": false } }, + "defaultValue": { + "description": "Value an optional field takes when it is omitted.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, "hint": { "description": "Provider-specific setup guidance.", "type": "string", @@ -18138,7 +18154,7 @@ "items": { "type": "string" }, - "description": "Fields `keylessConfig` may also carry." + "description": "Fields `keylessConfig` may also carry. `audience` is a custom `aud` claim Sim signs in place of the default; your trust configuration must then accept it." } }, "required": ["authType", "requiredConfigFields", "optionalConfigFields"], @@ -18513,6 +18529,7 @@ "ramp", "helpscout", "hubspot", + "apollo", "linkedin", "instagram", "salesforce", @@ -18616,7 +18633,7 @@ "description": "OpenID Connect issuer URL to register as the identity provider the cloud trusts." }, "audience": { - "description": "The `aud` claim the cloud must accept. Absent for Google Cloud, whose audience is each credential's own workload identity pool provider.", + "description": "The default `aud` claim the cloud must accept; a keyless config's optional `audience` replaces it. Absent for Google Cloud, whose default audience is each credential's own workload identity pool provider.", "type": "string" }, "subject": { @@ -18651,7 +18668,7 @@ }, "required": ["authType", "subject", "subjectProof"], "additionalProperties": false, - "description": "Prefilled keyless `serviceAccountJson`: add `requiredConfigFields`, then send it to Create service-account credential." + "description": "Prefilled keyless `serviceAccountJson`: add `requiredConfigFields` (and any `optionalConfigFields`, such as a custom `audience`), then send it to Create service-account credential." }, "requiredConfigFields": { "type": "array", @@ -18665,7 +18682,7 @@ "items": { "type": "string" }, - "description": "Fields `keylessConfig` may also carry." + "description": "Fields `keylessConfig` may also carry. `audience` replaces the default `audience` above with your own value, such as one specific to your account; trust that value instead of the default." } }, "required": [ @@ -18679,7 +18696,7 @@ ], "additionalProperties": false, "title": "Credential workload identity subject", - "description": "The issuer, audience, and subject a cloud trust configuration pins, and the proof that attaches the subject to a new keyless credential." + "description": "The issuer, default audience, and subject a cloud trust configuration pins, and the proof that attaches the subject to a new keyless credential. A `keylessConfig.audience` overrides the default audience." }, "CreateCredentialWorkloadIdentitySubjectResponse": { "type": "object", @@ -18707,7 +18724,7 @@ "subjectProof": "" }, "requiredConfigFields": ["roleArn"], - "optionalConfigFields": ["stsRegion", "sessionDurationSeconds"] + "optionalConfigFields": ["stsRegion", "sessionDurationSeconds", "audience"] } } ] @@ -21700,7 +21717,7 @@ "mode": { "type": "string", "const": "apiKey", - "description": "Authenticates with a stored API key; pass `apiKey`, and no `credentialId`, when creating the connector." + "description": "Authenticates with a stored API key; pass `apiKey` when creating the connector, or a saved `credentialId` instead when `savedCredential` is present." }, "label": { "description": "Label shown above the key field.", @@ -21713,13 +21730,29 @@ "optional": { "type": "boolean", "description": "Whether the key may be left blank, for a source reachable without authentication." + }, + "savedCredential": { + "description": "When present, a saved credential of this service may be passed as `credentialId` instead of `apiKey`, once saved-credential setup is enabled on this deployment (until then it is refused with a validation error). Fields marked `apiKeyOnly` are then omitted.", + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Service the saved credential must authenticate with, e.g. `aws`." + }, + "label": { + "type": "string", + "description": "Label for the saved-credential option." + } + }, + "required": ["provider", "label"], + "additionalProperties": false } }, "required": ["mode", "optional"], "additionalProperties": false } ], - "description": "How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey`." + "description": "How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey` (or, when they declare `savedCredential`, a saved `credentialId`)." }, "configFields": { "type": "array", @@ -21869,6 +21902,10 @@ "multi": { "description": "When true the stored `sourceConfig` value is a `string[]`, not a `string`: a `selector` renders a multi-select picker and a `short-input` accepts a comma-separated list.", "type": "boolean" + }, + "apiKeyOnly": { + "description": "When true the field describes the connector’s API key (such as its key ID or a custom host the key signs for). It is required as stated only with `apiKey`, and omitted when a saved `credentialId` is sent.", + "type": "boolean" } }, "required": ["id", "title", "type"], @@ -21897,7 +21934,7 @@ "mode": { "type": "string", "enum": ["oauth", "apiKey"], - "description": "How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey`." + "description": "How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey` (or, when they declare `savedCredential`, a saved `credentialId`)." } }, "required": ["mode"], @@ -22387,6 +22424,10 @@ "maxLength": 1024 }, "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." + }, + "disableComputerUse": { + "type": "boolean", + "description": "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it." } }, "required": [ @@ -22434,7 +22475,8 @@ "disableKnowledgeBaseExport", "deniedPartialAccessProjectIssues", "defaultAgentModel", - "deniedAccessRequests" + "deniedAccessRequests", + "disableComputerUse" ], "additionalProperties": false, "description": "Resolved group settings. True disables a boolean capability; null allowlists permit every value and empty allowlists permit none. defaultAgentModel selects the default for new Agent blocks without restricting model selection." @@ -22567,7 +22609,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -22645,7 +22688,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -22929,6 +22973,10 @@ "maxLength": 1024 }, "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." + }, + "disableComputerUse": { + "type": "boolean", + "description": "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it." } }, "additionalProperties": false, @@ -23023,7 +23071,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -23099,7 +23148,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -23390,6 +23440,10 @@ "maxLength": 1024 }, "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." + }, + "disableComputerUse": { + "type": "boolean", + "description": "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it." } }, "additionalProperties": false, @@ -25887,6 +25941,10 @@ "maxLength": 1024 }, "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." + }, + "disableComputerUse": { + "type": "boolean", + "description": "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it." } }, "required": [ @@ -25934,7 +25992,8 @@ "disableKnowledgeBaseExport", "deniedPartialAccessProjectIssues", "defaultAgentModel", - "deniedAccessRequests" + "deniedAccessRequests", + "disableComputerUse" ], "additionalProperties": false }, @@ -26694,7 +26753,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -27006,7 +27066,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -27406,7 +27467,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -27804,7 +27866,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -28160,7 +28223,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -28419,7 +28483,7 @@ "description": "Access request being reviewed." }, "changes": { - "maxItems": 43, + "maxItems": 44, "type": "array", "items": { "type": "object", @@ -28469,7 +28533,8 @@ "hideSandboxesTab", "disableOAuthAppAccess", "disableKnowledgeBaseExport", - "deniedPartialAccessProjectIssues" + "deniedPartialAccessProjectIssues", + "disableComputerUse" ], "description": "Permission restriction changed by approval." }, @@ -28722,7 +28787,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -28981,7 +29047,7 @@ "description": "Access request being reviewed." }, "changes": { - "maxItems": 43, + "maxItems": 44, "type": "array", "items": { "type": "object", @@ -29031,7 +29097,8 @@ "hideSandboxesTab", "disableOAuthAppAccess", "disableKnowledgeBaseExport", - "deniedPartialAccessProjectIssues" + "deniedPartialAccessProjectIssues", + "disableComputerUse" ], "description": "Permission restriction changed by approval." }, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 27407f0f794..7918cdc1183 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -15969,7 +15969,7 @@ "failed", "cancelled" ], - "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting at a human or time pause, or a resume attempt did not complete. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason." + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run has a human or time pause that can still be resumed; once the run has finished and every pause point has resumed or failed, it reports the finished status. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason." }, "trigger": { "type": "string", @@ -16468,7 +16468,7 @@ "cancelled", "queued" ], - "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting at a human or time pause, or a resume attempt did not complete. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason." + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run has a human or time pause that can still be resumed; once the run has finished and every pause point has resumed or failed, it reports the finished status. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason." }, "trigger": { "anyOf": [ diff --git a/apps/docs/public/static/examples/gitlab/export-permissions.py b/apps/docs/public/static/examples/gitlab/export-permissions.py new file mode 100644 index 00000000000..33a53d049c5 --- /dev/null +++ b/apps/docs/public/static/examples/gitlab/export-permissions.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Export one GitLab project's readers to Sim's permission CSVs (Python 3.10+).""" + +import argparse +import csv +from datetime import date, datetime, timezone +import io +import json +import os +from pathlib import Path +import re +import ssl +import sys +import tempfile +import time +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener + + +MAX_BYTES = 4 * 1024 * 1024 +MAX_ROWS = 100_000 +EMAIL = re.compile( + r"[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@" + r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*" +) + + +class ExportError(Exception): + """An incomplete or unsafe export must not be uploaded.""" + + +class NoRedirects(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + # A redirect must never forward the private token to another host. + return None + + +class GitLab: + def __init__(self, base_url, token, requests_per_minute): + parsed = urlsplit(base_url) + if (parsed.scheme != "https" or not parsed.hostname or parsed.username + or parsed.password or parsed.query or parsed.fragment + or any(ord(char) < 33 for char in base_url)): + raise ExportError("Use an HTTPS GitLab instance URL without credentials, query, or fragment.") + self.base = base_url.rstrip("/") + "/api/v4/" + self.token = token + self.interval = 60 / requests_per_minute + self.last_request = 0.0 + self.opener = build_opener(NoRedirects(), HTTPSHandler(context=ssl.create_default_context())) + + def get(self, path, query=None): + delay = self.interval - (time.monotonic() - self.last_request) + if delay > 0: + time.sleep(delay) + url = self.base + path + ("?" + urlencode(query) if query else "") + request = Request(url, headers={"PRIVATE-TOKEN": self.token, "Accept": "application/json"}) + self.last_request = time.monotonic() + try: + with self.opener.open(request, timeout=30) as response: + raw = response.read(8 * 1024 * 1024 + 1) + headers = response.headers + except HTTPError as error: + raise ExportError(f"GitLab GET {path} failed (HTTP {error.code}); no export published.") from None + except (URLError, TimeoutError, OSError): + raise ExportError("GitLab connection failed. Check the host, HTTPS trust, and network access.") from None + if len(raw) > 8 * 1024 * 1024: + raise ExportError("GitLab response exceeds the 8 MiB limit.") + try: + return json.loads(raw), headers + except (ValueError, UnicodeError): + raise ExportError("GitLab returned invalid JSON.") from None + + +def identifier(value): + if type(value) is not int or not 0 < value <= 9_007_199_254_740_991: + raise ExportError("GitLab returned an invalid numeric identity.") + return value + + +def active(record): + if not isinstance(record, dict) or not isinstance(record.get("state"), str): + raise ExportError("GitLab returned a user without an account state.") + return record["state"] == "active" and not record.get("locked", False) + + +def export(client, project): + record, _ = client.get("projects/" + quote(project, safe="")) + if not isinstance(record, dict): + raise ExportError("GitLab returned an invalid project.") + project_id = identifier(record.get("id")) + project_path = record.get("path_with_namespace") + if (not isinstance(project_path, str) or len(project_path) > 1024 + or "/" not in project_path + or any(not part or part in (".", "..") + or not all(char.isalnum() or char in "_.-" for char in part) + for part in project_path.split("/"))): + raise ExportError("GitLab returned an invalid project path.") + users, emails, seen_members = {}, {}, set() + today = datetime.now(timezone.utc).date() + page = 1 + expected_total = None + while page <= 1000: + members, headers = client.get(f"projects/{project_id}/members/all", + {"per_page": 100, "page": page}) + if not isinstance(members, list) or len(members) > 100: + raise ExportError("GitLab returned an invalid membership page.") + total = headers.get("X-Total") + if total is not None: + if not re.fullmatch(r"[0-9]{1,6}", total) or int(total) > MAX_ROWS: + raise ExportError("GitLab membership total is invalid or exceeds the 100,000-row limit.") + if expected_total is not None and int(total) != expected_total: + raise ExportError("GitLab membership total changed during pagination. Rerun the export.") + expected_total = int(total) + for member in members: + if not isinstance(member, dict): + raise ExportError("GitLab returned an invalid membership.") + user_id = identifier(member.get("id")) + if user_id in seen_members: + raise ExportError("GitLab membership pagination repeated a user. Rerun the export.") + seen_members.add(user_id) + if len(seen_members) > MAX_ROWS: + raise ExportError("Project membership exceeds the 100,000-row limit.") + role = member.get("access_level") + if type(role) is not int: + raise ExportError("GitLab returned a membership without an access level.") + if not active(member) or role < 20: + continue + expiry = member.get("expires_at") + if expiry is not None: + try: + expired = date.fromisoformat(expiry) <= today + except (TypeError, ValueError): + raise ExportError("GitLab returned an invalid membership expiry.") from None + if expired: + continue + profile, _ = client.get(f"users/{user_id}") + if not isinstance(profile, dict) or identifier(profile.get("id")) != user_id: + raise ExportError("GitLab returned a mismatched user identity.") + if not active(profile) or profile.get("bot", False): + continue + confirmed_at = profile.get("confirmed_at") + if not isinstance(confirmed_at, str): + continue + try: + datetime.fromisoformat(confirmed_at.replace("Z", "+00:00")) + except ValueError: + continue + email = profile.get("email") + if not isinstance(email, str): + raise ExportError( + f"User {user_id}: primary email is unavailable. The CI token needs directory email access." + ) + email = email.strip().lower() + if len(email) > 254 or not EMAIL.fullmatch(email): + raise ExportError(f"User {user_id}: primary email is invalid.") + if email in emails and emails[email] != user_id: + raise ExportError("Multiple GitLab users share an email. Resolve the identity conflict first.") + emails[email] = user_id + users[user_id] = email + next_page = headers.get("X-Next-Page") + if next_page is None: + next_page = str(page + 1) if len(members) == 100 else "" + if not next_page: + break + if not re.fullmatch(r"[0-9]+", next_page) or int(next_page) != page + 1 or not members: + raise ExportError("GitLab returned invalid membership pagination.") + page = int(next_page) + else: + raise ExportError("GitLab membership pagination exceeds 1,000 pages.") + if expected_total is not None and len(seen_members) != expected_total: + raise ExportError("Exported membership count does not match GitLab's total. Rerun the export.") + if not users: + raise ExportError("No eligible readers. Pause search in Sim; empty CSVs cannot revoke all grants.") + return project_path, users + + +def write_export(output, project_path, users): + files = {} + ordered = sorted(users) + for name, header, rows in [ + ("users.csv", ["user_id", "email"], [(user_id, users[user_id]) for user_id in ordered]), + ("project-permissions.csv", ["project_path", "user_id"], + [(project_path, user_id) for user_id in ordered]), + ]: + buffer = io.StringIO(newline="") + writer = csv.writer(buffer, lineterminator="\n") + writer.writerow(header) + writer.writerows(rows) + files[name] = buffer.getvalue().encode("utf-8") + if len(files[name]) > MAX_BYTES: + raise ExportError(f"{name} exceeds Sim's 4 MiB CSV limit.") + output.parent.mkdir(parents=True, exist_ok=True) + # Publish the pair together, so an interrupted export cannot mix new and old CSVs. + with tempfile.TemporaryDirectory(dir=output.parent, prefix=".gitlab-export-") as temporary: + for name, content in files.items(): + path = Path(temporary) / name + path.write_bytes(content) + path.chmod(0o600) + os.rename(temporary, output) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gitlab-url", required=True, help="HTTPS instance URL, without /api/v4") + parser.add_argument("--project", required=True, help="Project ID or full namespace/project path") + parser.add_argument("--output-dir", default="gitlab-permissions", help="New directory for both CSVs") + parser.add_argument("--requests-per-minute", type=int, default=60, help="Request-start ceiling (default: 60)") + args = parser.parse_args() + token = os.environ.get("GITLAB_EXPORT_TOKEN", "") + if not token or any(char.isspace() for char in token): + parser.error("Set GITLAB_EXPORT_TOKEN to the CI export token.") + if args.requests_per_minute <= 0: + parser.error("--requests-per-minute must be positive.") + output = Path(args.output_dir).absolute() + if output.exists() or output.is_symlink(): + raise ExportError("Output directory already exists. Choose a new directory for a fresh export.") + client = GitLab(args.gitlab_url, token, args.requests_per_minute) + project_path, users = export(client, args.project) + write_export(output, project_path, users) + print(f"Exported {len(users)} readers to {output}/users.csv and {output}/project-permissions.csv") + + +if __name__ == "__main__": + try: + main() + except (ExportError, OSError) as error: + message = str(error) if isinstance(error, ExportError) else "Could not publish the CSV files." + print(f"Export failed: {message}", file=sys.stderr) + sys.exit(1) diff --git a/apps/docs/scripts/fixtures/content-baseline.json b/apps/docs/scripts/fixtures/content-baseline.json index 0b72b79d282..374997b16ac 100644 --- a/apps/docs/scripts/fixtures/content-baseline.json +++ b/apps/docs/scripts/fixtures/content-baseline.json @@ -67119,6 +67119,13 @@ "sha256": "b867716c5b178cc345e9ec091ffd5db588c778b02f244e3e51513c9266fbaf8a", "bytes": 733, "line": 199 + }, + { + "lang": "bash", + "meta": null, + "sha256": "9027a7d2a3cca34b202d5fdb0c5166f12a6273f412dfe19b918cdedc9a330c8f", + "bytes": 115, + "line": 236 } ], "links": [ @@ -141594,6 +141601,13 @@ "bytes": 169, "line": 29 }, + { + "lang": "sql", + "meta": null, + "sha256": "8ba22e026d880965c3aa9a33b6d11244442b5057801b4d08e6d031d5178b950e", + "bytes": 86, + "line": 42 + }, { "lang": "sql", "meta": null, @@ -177203,21 +177217,28 @@ "meta": null, "sha256": "68cb9b0d31751b48e06f8d452f15821f1e68ee3816f17638a88294587a58908c", "bytes": 55, - "line": 48 + "line": 52 }, { "lang": "csv", "meta": null, "sha256": "5c2ebfcc044ebc01c17a4af2664a4fbc667ae4916ef21555d90cfd9f245394bd", "bytes": 70, - "line": 56 + "line": 60 + }, + { + "lang": "bash", + "meta": null, + "sha256": "473dea567e9f48d8b1fa1dded81bf8a30ce177ea12338164f05d3fd015570637", + "bytes": 154, + "line": 94 }, { "lang": "yaml", "meta": "title=\".gitlab-ci.yml\"", - "sha256": "bf3d6e7bec6b87f3966580713633eb8baed778c9dced4569a65cc62d790dee42", - "bytes": 1084, - "line": 98 + "sha256": "29261e0a66aac3cf52e7b9164aaa44f235765bcfd2cae4e68d744d31b29b57bf", + "bytes": 1441, + "line": 129 } ], "links": [ diff --git a/apps/docs/scripts/test-gitlab-export-e2e.py b/apps/docs/scripts/test-gitlab-export-e2e.py new file mode 100644 index 00000000000..8f3075e033b --- /dev/null +++ b/apps/docs/scripts/test-gitlab-export-e2e.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Exercise the downloadable exporter over HTTPS, in a separate process.""" + +import csv +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import ssl +import subprocess +import sys +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + + +SCRIPT = Path(__file__).resolve().parents[1] / "public/static/examples/gitlab/export-permissions.py" +TOKEN = "fixture-token-must-not-appear-in-output" +SIM_ROOT = Path(__file__).resolve().parents[2] / "sim" + + +def member(user_id, **overrides): + return {"id": user_id, "state": "active", "access_level": 30, + "expires_at": None, **overrides} + + +class GitLabFixture(BaseHTTPRequestHandler): + calls = [] + + def log_message(self, *_args): + pass + + def do_GET(self): + parsed = urlsplit(self.path) + scenario, endpoint = parsed.path.lstrip("/").split("/api/v4/", 1) + self.calls.append((scenario, endpoint, parse_qs(parsed.query), time.monotonic())) + if self.headers.get("PRIVATE-TOKEN") != TOKEN: + self.reply(401, {"message": "Missing token"}) + return + if scenario == "denied": + self.reply(403, {"message": TOKEN}) + return + if endpoint in ("projects/engineering%2Fplatform", "projects/91"): + if scenario == "redirect": + self.reply(302, {}, {"Location": "/redirect/api/v4/trap"}) + else: + self.reply(200, {"id": 91, "path_with_namespace": "engineering/platform", + "repository_access_level": "disabled" if scenario == "issues-only" else "enabled", + "issues_access_level": "enabled", "wiki_access_level": "enabled"}) + elif endpoint == "projects/91/members/all": + page = parse_qs(parsed.query).get("page", ["1"])[0] + total = "11" if scenario in ("truncated-membership", "changing-total") else "10" + if scenario == "changing-total" and page == "2": + total = "10" + headers = {} if scenario == "missing-pagination" else {"X-Total": total} + if scenario == "page-failure" and page == "2": + self.reply(500, {"message": TOKEN}) + elif scenario == "pagination-loop": + self.reply(200, [member(1)], {"X-Next-Page": "1"}) + elif scenario == "missing-pagination" and page == "1": + self.reply(200, [member(i, access_level=10) for i in range(100, 200)]) + elif scenario == "empty": + self.reply(200, [member(3, access_level=10)]) + elif page == "1": + self.reply(200, [member(2), member(1)], {**headers, "X-Next-Page": "2"}) + else: + self.reply(200, [member(3, access_level=10), member(4, state="blocked"), + member(5), member(6, expires_at="2000-01-01"), + member(7), member(8, access_level=15), member(9), + member(10, expires_at=datetime.now(timezone.utc).date().isoformat())], + {**headers, "X-Next-Page": ""}) + elif endpoint.startswith("users/"): + user_id = int(endpoint.split("/")[1]) + email = {1: "ALICE@example.com", 2: "bob@example.com", 7: "carol@example.com"}.get( + user_id, f"service-{user_id}@example.com") + if scenario == "duplicate-email" and user_id == 2: + email = "alice@example.com" + profile = {"id": user_id, "state": "active", "locked": user_id == 9, + "bot": user_id == 5, "email": email, "confirmed_at": "2025-01-01T00:00:00Z"} + if user_id == 2: + if scenario == "unconfirmed-email": + profile["confirmed_at"] = None + elif scenario == "missing-confirmation": + profile.pop("confirmed_at") + elif scenario == "invalid-confirmation": + profile["confirmed_at"] = "not-a-timestamp" + if scenario == "hidden-email": + profile.pop("email") + profile["public_email"] = f"public-alias-{user_id}@example.com" + if scenario == "wrong-identity": + profile["id"] = 999 + self.reply(200, profile) + else: + self.reply(404, {"message": "Unexpected endpoint"}) + + def reply(self, status, body, headers=None): + data = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + for key, value in (headers or {}).items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(data) + + +def run(): + report_path = Path(os.environ["GITLAB_EXPORT_REPORT_PATH"]) + report = {"boundary": "CLI subprocess against a local HTTPS GitLab API fixture", "checks": []} + try: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + cert, key = root / "cert.pem", root / "key.pem" + subprocess.run([ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-keyout", str(key), "-out", str(cert), "-subj", "/CN=localhost", + "-addext", "subjectAltName=IP:127.0.0.1", + ], check=True, capture_output=True) + server = ThreadingHTTPServer(("127.0.0.1", 0), GitLabFixture) + tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + tls.load_cert_chain(cert, key) + server.socket = tls.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + for scenario, expected_error in [ + ("success", None), ("numeric-project", None), ("missing-pagination", None), + ("issues-only", None), ("paced", None), + ("unconfirmed-email", None), ("missing-confirmation", None), + ("invalid-confirmation", None), + ("truncated-membership", "total"), ("changing-total", "changed"), + ("hidden-email", "email"), ("duplicate-email", "email"), + ("wrong-identity", "identity"), ("denied", "403"), + ("page-failure", "500"), ("pagination-loop", "pagination"), + ("redirect", "302"), ("empty", "readers"), + ("existing-output", "exists"), + ]: + output = root / scenario + if scenario == "existing-output": + output.mkdir() + (output / "users.csv").write_text("keep the previous export") + command = [sys.executable, str(SCRIPT), "--gitlab-url", + f"https://127.0.0.1:{server.server_port}/{scenario}", + "--project", "91" if scenario == "numeric-project" else "engineering/platform", + "--output-dir", str(output), + "--requests-per-minute", "120" if scenario == "paced" else "600000"] + result = subprocess.run(command, capture_output=True, text=True, timeout=20, + env={**os.environ, "GITLAB_EXPORT_TOKEN": TOKEN, + "SSL_CERT_FILE": str(cert)}) + assert TOKEN not in result.stdout + result.stderr, "Token leaked in output" + if expected_error: + assert result.returncode != 0, f"{scenario}: export unexpectedly succeeded" + assert expected_error in result.stderr.lower(), (scenario, result.stderr) + if scenario == "existing-output": + assert (output / "users.csv").read_text() == "keep the previous export" + else: + assert not output.exists(), f"{scenario}: published a partial export" + else: + assert result.returncode == 0, (scenario, result.stderr) + with (output / "users.csv").open() as handle: + users = list(csv.reader(handle)) + with (output / "project-permissions.csv").open() as handle: + grants = list(csv.reader(handle)) + expected = [["7", "carol@example.com"]] if scenario == "missing-pagination" else [ + ["1", "alice@example.com"], ["2", "bob@example.com"], + ["7", "carol@example.com"]] + if scenario in ("unconfirmed-email", "missing-confirmation", "invalid-confirmation"): + expected = [["1", "alice@example.com"], ["7", "carol@example.com"]] + assert users == [["user_id", "email"], *expected], users + assert grants == [["project_path", "user_id"], *[ + ["engineering/platform", row[0]] for row in expected]], grants + parsed = subprocess.run([ + "bun", "-e", """ +import { parseGitLabCsv, gitLabCsvSubjects } from './connectors/gitlab/permission-config/parser' +const directory = process.argv[1] +const users = parseGitLabCsv({filename: 'users.csv', content: await Bun.file(`${directory}/users.csv`).text()}, 'userMapping') +const grants = parseGitLabCsv({filename: 'project-permissions.csv', content: await Bun.file(`${directory}/project-permissions.csv`).text()}, 'projectPermissions') +process.stdout.write(JSON.stringify({ + subjects: gitLabCsvSubjects(users, grants, 'engineering/platform'), + otherProject: gitLabCsvSubjects(users, grants, 'engineering/other'), +})) +""", str(output), + ], cwd=SIM_ROOT, capture_output=True, text=True, timeout=20) + assert parsed.returncode == 0, parsed.stderr + assert json.loads(parsed.stdout) == { + "subjects": sorted("u:" + row[1] for row in expected), + "otherProject": [], + }, parsed.stdout + if scenario == "paced": + arrivals = [arrived for case, _, _, arrived in GitLabFixture.calls if case == scenario] + # The first request's transit time can consume one pacing interval. + minimum_span = (len(arrivals) - 2) * 0.5 + assert arrivals[-1] - arrivals[0] >= minimum_span, "Requests exceeded the configured rate" + report["pacing"] = {"requests": len(arrivals), "requestsPerMinute": 120, + "observedSeconds": arrivals[-1] - arrivals[0], + "minimumSeconds": minimum_span} + report["checks"].append({"name": scenario, "passed": True}) + assert not any(endpoint == "trap" for _, endpoint, _, _ in GitLabFixture.calls) + report["passed"] = True + finally: + server.shutdown() + server.server_close() + thread.join() + except Exception as error: + report["passed"] = False + report["error"] = str(error) + raise + finally: + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2) + "\n") + print(f"Passed {len(report['checks'])} exporter checks. Report: {report_path}") + + +if __name__ == "__main__": + run() diff --git a/apps/sim/app/api/cron/aws-marketplace-meter/route.ts b/apps/sim/app/api/cron/aws-marketplace-meter/route.ts new file mode 100644 index 00000000000..467dbcf7a45 --- /dev/null +++ b/apps/sim/app/api/cron/aws-marketplace-meter/route.ts @@ -0,0 +1,67 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' +import { meterAwsMarketplaceUsage } from '@/lib/billing/aws-marketplace/metering' +import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { runDetached } from '@/lib/core/utils/background' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('AwsMarketplaceMeterCron') + +const LOCK_KEY = 'aws-marketplace-meter-lock' +/** Outlasts a run comfortably; the schedule runs every 15 minutes. */ +const LOCK_TTL_SECONDS = 10 * 60 + +export const dynamic = 'force-dynamic' + +/** + * Records newly closed hours of usage owed through AWS Marketplace and sends AWS what it has not + * accepted yet. Acknowledges the cron call at once and works in the background; a Redis lock keeps + * runs from overlapping, and the ledger makes a replayed run harmless. A no-op on deployments + * where no configured listing bills usage. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'AWS Marketplace metering') + if (authError) { + return authError + } + + if (!isAwsMarketplaceEnabled()) { + return NextResponse.json({ + success: true, + message: 'AWS Marketplace is not configured – skipped', + status: 'skip', + }) + } + + const lockValue = generateShortId() + const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, { + reclaimOnFailure: true, + }) + if (!locked) { + return NextResponse.json( + { + success: true, + message: 'AWS Marketplace metering already in progress – skipped', + status: 'skip', + }, + { status: 202 } + ) + } + + runDetached('aws-marketplace-meter', async () => { + try { + const summary = await meterAwsMarketplaceUsage() + if (summary) logger.info('AWS Marketplace metering completed', { ...summary }) + } finally { + await releaseLock(LOCK_KEY, lockValue).catch(() => {}) + } + }) + + return NextResponse.json( + { success: true, message: 'AWS Marketplace metering started', status: 'started' }, + { status: 202 } + ) +}) diff --git a/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts b/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts index b25b37572af..cdf42349fce 100644 --- a/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts +++ b/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' import { reconcileAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/reconcile' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { runDetached } from '@/lib/core/utils/background' @@ -20,7 +20,7 @@ export const dynamic = 'force-dynamic' * Applies AWS Marketplace agreement events from the SQS queue and re-verifies licenses no event * touched within a day. Acknowledges the cron call at once and works in the background; a Redis * lock keeps runs from overlapping, and every write is idempotent, so a replayed run is harmless. - * A no-op on deployments without `AWS_MARKETPLACE_PRODUCT_CODE`. + * A no-op on deployments without `AWS_MARKETPLACE_PRODUCTS`. */ export const GET = withRouteHandler(async (request: NextRequest) => { const authError = verifyCronAuth(request, 'AWS Marketplace reconcile') @@ -28,7 +28,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return authError } - if (!getAwsMarketplaceProductCode()) { + if (!isAwsMarketplaceEnabled()) { return NextResponse.json({ success: true, message: 'AWS Marketplace is not configured – skipped', diff --git a/apps/sim/app/api/desktop/computer/availability/route.ts b/apps/sim/app/api/desktop/computer/availability/route.ts deleted file mode 100644 index 884fe581ade..00000000000 --- a/apps/sim/app/api/desktop/computer/availability/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { computerUseAvailabilityContract } from '@/lib/api/contracts/computer-use' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, - internalSessionAuth, -} from '@/lib/api/server/routes' -import { readComputerUseAvailability } from '@/lib/computer-use/application/availability' - -export const GET = defineInternalJsonRoute({ - contract: computerUseAvailabilityContract, - auth: internalSessionAuth, - operation: readComputerUseAvailability.operation, - rateLimit: internalRateLimits.none({ - reason: 'Read-only rollout check used on each desktop chat admission.', - }), - errorPolicy: internalOrchestrationErrorPolicy, - mapInput: ({ query }) => query, - useCase: readComputerUseAvailability, - present: (result) => result, -}) diff --git a/apps/sim/app/api/marketplace/aws/register/route.ts b/apps/sim/app/api/marketplace/aws/register/route.ts index 0c4181072ae..796e54feefa 100644 --- a/apps/sim/app/api/marketplace/aws/register/route.ts +++ b/apps/sim/app/api/marketplace/aws/register/route.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { awsMarketplaceRegistrationFormSchema } from '@/lib/api/contracts/aws-marketplace' import { serializeClaimCookie } from '@/lib/billing/aws-marketplace/claim' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' import { AWS_MARKETPLACE_ONBOARDING_PATH, type AwsMarketplaceOnboardingError, @@ -59,8 +59,7 @@ async function readRegistrationToken(request: NextRequest): Promise { - const productCode = getAwsMarketplaceProductCode() - if (!productCode) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 }) try { const rateLimited = await enforceIpRateLimit( @@ -76,7 +75,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return redirectToOnboarding('invalid') } - const outcome = await registerAwsMarketplaceBuyer(productCode, token) + const outcome = await registerAwsMarketplaceBuyer(token) if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind) const response = redirectToOnboarding() @@ -91,7 +90,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { /** A refresh, back button, or bookmark of the fulfillment URL lands on the onboarding page. */ export const GET = withRouteHandler(async () => { - if (!getAwsMarketplaceProductCode()) - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return redirectToOnboarding() }) diff --git a/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts b/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts index fb549dc5e7b..edde3610a67 100644 --- a/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts +++ b/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts @@ -9,7 +9,7 @@ import { linkAwsMarketplaceLicenseAsAdmin, listUnlinkedAwsMarketplaceLicenses, } from '@/lib/billing/aws-marketplace/application/admin' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor' @@ -29,7 +29,7 @@ const logger = createLogger('AdminAwsMarketplaceLicensesAPI') /** The Marketplace licenses no organization holds yet. */ export const GET = withRouteHandler( withAdminAuth(async (request) => { - if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace') + if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace') const parsed = await parseRequest( adminDashboardListAwsMarketplaceLicensesContract, request, @@ -56,7 +56,7 @@ export const GET = withRouteHandler( /** Links a Marketplace license to an organization once the team has provisioned it. */ export const POST = withRouteHandler( withAdminAuth(async (request) => { - if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace') + if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace') const parsed = await parseRequest( adminDashboardLinkAwsMarketplaceLicenseContract, request, diff --git a/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts b/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts index 00f6c61d333..34db14a7e72 100644 --- a/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts +++ b/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts @@ -94,7 +94,7 @@ describe('POST /api/v2/credentials/workload-identity/subjects', () => { subjectProof: data.subjectProof, }, requiredConfigFields: ['roleArn'], - optionalConfigFields: ['stsRegion', 'sessionDurationSeconds'], + optionalConfigFields: ['stsRegion', 'sessionDurationSeconds', 'audience'], }) const owner = { workspaceId: WORKSPACE_ID } diff --git a/apps/sim/app/extension/providers.tsx b/apps/sim/app/extension/providers.tsx index 18e4cf02865..764bceb2e5d 100644 --- a/apps/sim/app/extension/providers.tsx +++ b/apps/sim/app/extension/providers.tsx @@ -55,7 +55,6 @@ export function ExtensionProviders({ changelog: false, 'mothership-model-selector': modelSelectorEnabled, 'mothership-plan-mode': planModeEnabled, - 'mothership-computer-use': false, }} > diff --git a/apps/sim/app/marketplace/aws/page.tsx b/apps/sim/app/marketplace/aws/page.tsx index 2ad232d3ba5..3f870179f35 100644 --- a/apps/sim/app/marketplace/aws/page.tsx +++ b/apps/sim/app/marketplace/aws/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' import { notFound } from 'next/navigation' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' import { AWS_MARKETPLACE_ONBOARDING_ERRORS } from '@/lib/billing/aws-marketplace/onboarding' import { isRegistrationDisabled } from '@/lib/core/config/env-flags' import { AwsMarketplaceOnboarding } from '@/app/marketplace/aws/aws-marketplace-onboarding' @@ -18,7 +18,7 @@ interface AwsMarketplacePageProps { /** Where AWS Marketplace buyers land after registration, to link their subscription. */ export default async function AwsMarketplacePage({ searchParams }: AwsMarketplacePageProps) { - if (!getAwsMarketplaceProductCode()) notFound() + if (!isAwsMarketplaceEnabled()) notFound() const { error } = await searchParams const registrationError = AWS_MARKETPLACE_ONBOARDING_ERRORS.find((code) => code === error) ?? null return ( diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index ddaa31bf583..018d7f5730f 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -7,7 +7,6 @@ import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { canUseBenchmarks } from '@/lib/benchmarks/config' import { isChangelogEnabled } from '@/lib/changelog/feature-flag' -import { isComputerUseAvailable } from '@/lib/computer-use/availability.server' import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' @@ -82,7 +81,6 @@ export default async function OrganizationLayout({ modelSelectorEnabled, dashboardsEnabled, benchmarkEnabled, - computerUseEnabled, workflowTestsEnabled, changelogEnabled, projectsEnabled, @@ -102,7 +100,6 @@ export default async function OrganizationLayout({ logger.warn('Could not resolve benchmark navigation access') return false }), - isComputerUseAvailable(organizationId), isWorkflowTestsEnabled(organizationId), isChangelogEnabled(organizationId), isProjectsEnabled(), @@ -124,7 +121,6 @@ export default async function OrganizationLayout({ 'workflow-tests': workflowTestsEnabled, changelog: changelogEnabled, 'mothership-model-selector': modelSelectorEnabled, - 'mothership-computer-use': computerUseEnabled, 'mothership-plan-mode': benchmarkEnabled, 'mothership-memory-spaces': benchmarkEnabled, projects: projectsEnabled, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/rollout-controls.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/rollout-controls.test.tsx index e88c7682c09..c45b418ede6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/rollout-controls.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/rollout-controls.test.tsx @@ -60,7 +60,6 @@ async function render(advanced: boolean, workspaceId?: string) { changelog: false, 'mothership-model-selector': advanced, 'mothership-plan-mode': true, - 'mothership-computer-use': false, }} > => { if (authType === 'oidc_role') { - return { authType, roleArn: roleArn.trim(), ...keylessSubjectFields(keyless) } + return { authType, roleArn: roleArn.trim(), ...keylessConfigFields(keyless) } } const keys = { accessKeyId: accessKeyId.trim(), secretAccessKey } if (authType === 'assume_role') { @@ -152,13 +152,8 @@ function AwsAccountForm({ } const trustPolicy = - authType === 'oidc_role' && keyless.trust?.audience - ? buildTrustPolicy( - keyless.trust.issuer, - keyless.trust.audience, - keyless.trust.subject, - roleArn - ) + authType === 'oidc_role' && keyless.trust && keyless.audience + ? buildTrustPolicy(keyless.trust.issuer, keyless.audience, keyless.trust.subject, roleArn) : null const action = credentialId ? 'Reconnect' : 'Add' const authOptions = [ @@ -189,7 +184,12 @@ function AwsAccountForm({ hint={AUTH_TYPE_HINTS[authType]} /> - {authType === 'oidc_role' && } + {authType === 'oidc_role' && ( + + )} {needsKeys && ( <> @@ -256,7 +256,7 @@ function AwsAccountForm({ {(aria) => ( ) } + const databaseConnectionDescriptor = getDatabaseConnectionDescriptor(serviceAccountProviderId) + if (databaseConnectionDescriptor) { + return ( + + ) + } const clientCredentialDescriptor = getClientCredentialAccountDescriptor(serviceAccountProviderId) if (clientCredentialDescriptor) { return ( @@ -643,9 +671,10 @@ type GoogleAuthType = 'json_key' | 'workload_identity' /** * Google service-account flow. Accepts the raw JSON key (paste or drag/drop) * and validates against the shared `serviceAccountJsonSchema` so the same - * shape errors render here as in the server route. For a Google Cloud service - * (or a keyless credential being reconnected) on a deployment that can sign - * workload-identity tokens, it also offers keyless Workload Identity Federation. + * shape errors render here as in the server route. For a Google Cloud service, + * a Workspace service it can delegate (or a keyless credential being + * reconnected) on a deployment that can sign workload-identity tokens, it also + * offers keyless Workload Identity Federation. */ function GoogleServiceAccountModal(props: GoogleServiceAccountModalProps) { if (!props.open) return null @@ -670,13 +699,14 @@ function GoogleServiceAccountModalForm({ oauthServiceId, onCreated, }: GoogleServiceAccountModalProps) { - const cloudService = Boolean(oauthServiceId && GOOGLE_KEYLESS_SERVICES.has(oauthServiceId)) + const delegableService = googleDelegationServiceFor(oauthServiceId) + const keylessService = Boolean(oauthServiceId && isGoogleKeylessService(oauthServiceId)) const keyless = useKeylessSetup({ provider: 'google', workspaceId, organizationId, credentialId, - enabled: cloudService || Boolean(credentialId), + enabled: keylessService || Boolean(credentialId), }) const keylessSubmit = useSavedConnectionSubmit({ providerId: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -696,7 +726,11 @@ function GoogleServiceAccountModalForm({ const authType: GoogleAuthType = offerKeyless ? (selectedAuthType ?? keyless.reconnectAuthType ?? 'json_key') : 'json_key' - const [keylessInput, setKeylessInput] = useState(EMPTY_GOOGLE_KEYLESS_INPUT) + // Untouched, the form follows the reconnected credential's stored delegation, which + // arrives with the keyless setup the keyless fields wait for. + const [editedKeylessInput, setKeylessInput] = useState(null) + const keylessInput = + editedKeylessInput ?? emptyGoogleKeylessInput(oauthServiceId, keyless.reconnectDelegation) const [jsonInput, setJsonInput] = useState('') const [uploadedFileName, setUploadedFileName] = useState(null) const [displayName, setDisplayName] = useState(initialDisplayName ?? '') @@ -791,7 +825,7 @@ function GoogleServiceAccountModalForm({ const isDisabled = cloudOnly || (authType === 'workload_identity' - ? isPending || !keyless.ready || !isGoogleKeylessInputComplete(keylessInput) + ? isPending || !keyless.ready || !isGoogleKeylessInputComplete(keylessInput, delegableService) : !jsonInput.trim() || isPending) const submitError = authType === 'workload_identity' ? keylessSubmit.error : error @@ -825,7 +859,7 @@ function GoogleServiceAccountModalForm({ required hint={ authType === 'workload_identity' - ? 'No key is stored. Sim signs a short-lived token your workload identity provider trusts, then impersonates the service account. Google Cloud APIs only, without domain-wide delegation.' + ? 'No key is stored. Sim signs a short-lived token your workload identity provider trusts, then impersonates the service account: Google Cloud APIs, and Google Workspace services through domain-wide delegation.' : undefined } /> @@ -844,8 +878,9 @@ function GoogleServiceAccountModalForm({ {cloudOnly && ( - This keyless credential works only with Google Cloud services (BigQuery, Vertex AI). - Reconnect it from one of those integrations or from the credential’s own page. + This keyless credential works only with Google Cloud services (BigQuery, Vertex AI) and, + through domain-wide delegation, {GOOGLE_DELEGATION_SERVICE_NAMES}. Reconnect it from one + of those integrations or from the credential’s own page. )} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/database-connection-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/database-connection-account-modal.tsx new file mode 100644 index 00000000000..40b5eb7dfe7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/database-connection-account-modal.tsx @@ -0,0 +1,273 @@ +'use client' + +import { type ComponentType, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + SecretInput, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { isApiClientError } from '@/lib/api/client/errors' +import { + resourceScopeFields, + resourceScopeFromOwner, + resourceScopeKey, +} from '@/lib/core/resource-scope' +import type { DatabaseConnectionDescriptor } from '@/lib/credentials/database-connections/descriptors' +import type { ServiceAccountSecretFields } from '@/lib/credentials/service-account-secret' +import { withBrandIcon } from '@/blocks/brand-icon' +import { + useCreateScopedCredential, + useUpdateScopedCredential, +} from '@/hooks/queries/scoped-credentials' + +interface DatabaseConnectionAccountModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId?: string + organizationId?: string + descriptor: DatabaseConnectionDescriptor + serviceIcon: ComponentType<{ className?: string }> + credentialId?: string + initialDisplayName?: string + initialDescription?: string + onCreated?: (credentialId: string) => void + onSubmitCredentials?: ( + fields: ServiceAccountSecretFields & { displayName?: string; description?: string } + ) => Promise + submitPending?: boolean +} + +const MAX_PORT = 65535 + +interface LoginFields { + host: string + port: string + database: string + username: string + password: string +} + +/** + * Saved PostgreSQL, MySQL, and SQL Server connections share the standard + * credential create/reconnect lifecycle; the server proves each with + * `SELECT 1` before storing it. + */ +export function DatabaseConnectionAccountModal(props: DatabaseConnectionAccountModalProps) { + if (!props.open) return null + return ( + + ) +} + +function DatabaseConnectionAccountForm({ + open, + onOpenChange, + workspaceId, + organizationId, + descriptor, + serviceIcon: ServiceIcon, + credentialId, + initialDisplayName, + initialDescription, + onCreated, + onSubmitCredentials, + submitPending, +}: DatabaseConnectionAccountModalProps) { + const [login, setLogin] = useState({ + host: '', + port: String(descriptor.defaultPort), + database: '', + username: '', + password: '', + }) + const [tls, setTls] = useState>(() => + Object.fromEntries(descriptor.tls.map((field) => [field.id, field.defaultValue])) + ) + const [displayName, setDisplayName] = useState(initialDisplayName ?? '') + const [description, setDescription] = useState(initialDescription ?? '') + const [error, setError] = useState(null) + const createCredential = useCreateScopedCredential() + const updateCredential = useUpdateScopedCredential() + const isPending = + createCredential.isPending || updateCredential.isPending || submitPending === true + const action = credentialId ? 'Reconnect' : 'Add' + const title = `${action} ${descriptor.label} connection` + const port = login.port.trim() + const portIsValid = /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= MAX_PORT + const disabled = + isPending || + !login.host.trim() || + !portIsValid || + !login.database.trim() || + !login.username.trim() || + !login.password + const setLoginField = (field: keyof LoginFields, value: string) => { + setLogin((current) => ({ ...current, [field]: value })) + setError(null) + } + const submit = async () => { + if (disabled) return + setError(null) + const serviceAccountJson = JSON.stringify({ + host: login.host.trim(), + port: Number(port), + database: login.database.trim(), + username: login.username.trim(), + password: login.password, + ...tls, + }) + try { + let connectedId = credentialId + if (onSubmitCredentials) { + connectedId = await onSubmitCredentials({ + serviceAccountJson, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) + } else if (credentialId) { + await updateCredential.mutateAsync({ + ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), + credentialId, + serviceAccountJson, + displayName: displayName.trim() || undefined, + description: description.trim() || null, + }) + } else { + const created = await createCredential.mutateAsync({ + ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), + type: 'service_account', + providerId: descriptor.providerId, + serviceAccountJson, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) + connectedId = created.credential.id + } + if (connectedId) onCreated?.(connectedId) + onOpenChange(false) + } catch (submitError) { + setError( + isApiClientError(submitError) && submitError.code === 'duplicate_display_name' + ? 'A credential with that name already exists. Choose a different display name.' + : onSubmitCredentials + ? getErrorMessage(submitError, `Could not verify the ${descriptor.label} connection.`) + : `Could not verify the ${descriptor.label} connection. Check the connection details, network access, and database permissions.` + ) + } + } + return ( + + onOpenChange(false)}> + {title} + + + setLoginField('host', value)} + placeholder='db.example.com' + required + autoComplete='off' + /> + setLoginField('port', value)} + placeholder={String(descriptor.defaultPort)} + required + autoComplete='off' + error={ + port !== '' && !portIsValid ? `Enter a whole number from 1 to ${MAX_PORT}.` : undefined + } + /> + setLoginField('database', value)} + placeholder='your_database' + required + autoComplete='off' + /> + setLoginField('username', value)} + placeholder={descriptor.usernamePlaceholder} + required + autoComplete='off' + /> + + {(aria) => ( + setLoginField('password', value)} + placeholder='Enter the database password' + autoComplete='new-password' + data-lpignore='true' + /> + )} + + {descriptor.tls.map((field) => ( + { + setTls((current) => ({ ...current, [field.id]: value })) + setError(null) + }} + options={[...field.options]} + placeholder={`Choose ${field.label.toLowerCase()}`} + required + hint={field.hint} + /> + ))} + + + {error} + + onOpenChange(false)} + secondaryActions={[ + { + label: 'Setup guide', + onClick: () => window.open(descriptor.docsUrl, '_blank', 'noopener,noreferrer'), + }, + ]} + primaryAction={{ + label: isPending ? 'Verifying...' : `${action} connection`, + onClick: submit, + disabled, + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/databricks-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/databricks-account-modal.tsx index ade9ff7ff35..3a0953a6b53 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/databricks-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/databricks-account-modal.tsx @@ -15,7 +15,7 @@ import { resourceScopeFromOwner, resourceScopeKey } from '@/lib/core/resource-sc import { DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { KeylessTrustFields, - keylessSubjectFields, + keylessConfigFields, useKeylessSetup, useSavedConnectionSubmit, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection' @@ -118,7 +118,7 @@ function DatabricksAccountForm({ const buildConfig = (): Record => { const base = { authType, host: host.trim() } if (authType === 'oidc_federation') { - return { ...base, clientId: clientId.trim(), ...keylessSubjectFields(keyless) } + return { ...base, clientId: clientId.trim(), ...keylessConfigFields(keyless) } } if (authType === 'service_principal') { return { ...base, clientId: clientId.trim(), clientSecret } @@ -127,12 +127,8 @@ function DatabricksAccountForm({ } const federationCommand = - authType === 'oidc_federation' && keyless.trust?.audience - ? buildFederationPolicyCommand( - keyless.trust.issuer, - keyless.trust.audience, - keyless.trust.subject - ) + authType === 'oidc_federation' && keyless.trust && keyless.audience + ? buildFederationPolicyCommand(keyless.trust.issuer, keyless.audience, keyless.trust.subject) : null const action = credentialId ? 'Reconnect' : 'Add' const authOptions = [ @@ -214,7 +210,12 @@ function DatabricksAccountForm({ )} - {authType === 'oidc_federation' && } + {authType === 'oidc_federation' && ( + + )} {federationCommand && ( ({ + value: service, + label: getServiceConfigByServiceId(service)?.name ?? service, +})) + +/** "Gmail, Google Drive, …, and Google Calendar": the services keyless credentials can delegate. */ +export const GOOGLE_DELEGATION_SERVICE_NAMES = new Intl.ListFormat('en', { + style: 'long', + type: 'conjunction', +}).format(DELEGATION_OPTIONS.map((option) => option.label)) + +/** Whether the provider and service account fields have Google's documented format. */ +function isTrustComplete(value: GoogleKeylessInput): boolean { + return ( + isGoogleProjectNumber(value.projectNumber) && + isGoogleWorkloadIdentityId(value.poolId) && + isGoogleWorkloadIdentityId(value.providerId) && + isGoogleServiceAccountEmail(value.serviceAccountEmail) + ) } function normalize(input: GoogleKeylessInput): GoogleKeylessInput { @@ -35,23 +78,41 @@ function normalize(input: GoogleKeylessInput): GoogleKeylessInput { poolId: input.poolId.trim(), providerId: input.providerId.trim(), serviceAccountEmail: input.serviceAccountEmail.trim().toLowerCase(), + delegatedServices: input.delegatedServices, + delegatedUser: input.delegatedUser.trim().toLowerCase(), } } -/** Whether every field has Google's documented format, so the commands can be shown. */ -export function isGoogleKeylessInputComplete(input: GoogleKeylessInput): boolean { +/** + * Whether every field has Google's documented format, so the commands can be + * shown, and a delegated credential names its user. A dialog opened for a + * delegable Workspace service requires that service, since the credential is + * useless there without it. + */ +export function isGoogleKeylessInputComplete( + input: GoogleKeylessInput, + requiredService?: GoogleDelegationService +): boolean { const value = normalize(input) + const delegated = value.delegatedServices.length > 0 return ( - isGoogleProjectNumber(value.projectNumber) && - isGoogleWorkloadIdentityId(value.poolId) && - isGoogleWorkloadIdentityId(value.providerId) && - isGoogleServiceAccountEmail(value.serviceAccountEmail) + isTrustComplete(value) && + (!requiredService || value.delegatedServices.includes(requiredService)) && + (!delegated || isGoogleDelegationUser(value.delegatedUser)) ) } /** The `serviceAccountJson` a keyless Google credential is submitted as. */ export function buildGoogleKeylessConfig(input: GoogleKeylessInput, setup: KeylessSetup) { - return { authType: 'workload_identity', ...normalize(input), ...keylessSubjectFields(setup) } + const { delegatedServices, delegatedUser, ...ref } = normalize(input) + return { + authType: 'workload_identity', + ...ref, + ...(delegatedServices.length > 0 + ? { delegation: { user: delegatedUser, services: delegatedServices } } + : {}), + ...keylessConfigFields(setup), + } } interface GoogleKeylessFieldsProps { @@ -62,22 +123,26 @@ interface GoogleKeylessFieldsProps { /** * The workload identity provider and service account of a keyless Google - * Cloud credential, plus the trust values and gcloud commands to paste. + * credential, its optional domain-wide delegation, plus the trust values, + * gcloud commands, and Admin console scopes to paste. */ export function GoogleKeylessFields({ input, onChange, setup }: GoogleKeylessFieldsProps) { - const complete = isGoogleKeylessInputComplete(input) const value = normalize(input) - const audience = complete ? googleWorkloadIdentityAudience(value) : undefined + const trustComplete = isTrustComplete(value) + const delegated = value.delegatedServices.length > 0 + const providerAudience = trustComplete ? googleWorkloadIdentityAudience(value) : undefined const commands = - complete && setup.trust + trustComplete && setup.trust && !setup.audienceError ? googleWorkloadIdentitySetupCommands({ ref: value, serviceAccountEmail: value.serviceAccountEmail, issuer: setup.trust.issuer, subject: setup.trust.subject, + domainWideDelegation: delegated, + audience: setup.customAudience, }) : null - const edit = (field: keyof GoogleKeylessInput) => (next: string) => + const edit = (field: Exclude) => (next: string) => onChange({ ...input, [field]: next }) return ( @@ -122,12 +187,61 @@ export function GoogleKeylessFields({ input, onChange, setup }: GoogleKeylessFie autoComplete='off' hint='A user-managed service account only; default …@developer.gserviceaccount.com accounts are refused. Grant it the roles your workflows need, such as BigQuery or Vertex AI roles.' /> - + + {(aria) => ( + + onChange({ + ...input, + delegatedServices: GOOGLE_DELEGATION_SERVICES.filter((service) => + services.includes(service) + ), + }) + } + options={DELEGATION_OPTIONS} + allLabel='None (Google Cloud APIs only)' + showAllOption={false} + /> + )} + + {delegated && ( + <> + + + + )} + {commands && ( {(aria) => ( ({ credentialId, }) const trust = setup.data?.available ? setup.data : undefined + // Undefined until edited, so a reconnect opens on its stored audience once that loads. + const [audienceInput, setAudienceInput] = useState() + const audienceValue = audienceInput ?? setup.data?.reconnectAudience ?? '' + const defaultAudience = setup.data?.available ? setup.data.audience : undefined + const typedAudience = audienceValue.trim() + const typedCustom = typedAudience && typedAudience !== defaultAudience ? typedAudience : undefined + const audienceCheck = typedCustom + ? workloadIdentityAudienceSchema(provider).safeParse(typedCustom) + : undefined + const audienceError = + audienceCheck && !audienceCheck.success ? audienceCheck.error.issues[0]?.message : undefined + const customAudience = audienceError ? undefined : typedCustom const storedAuthType = providerAuthType(provider, setup.data?.reconnectAuthType) // A keyless credential reopens on keyless only where this deployment can still sign. const reconnectAuthType = @@ -73,13 +86,33 @@ export function useKeylessSetup

    ({ available: workspaceScoped ? setup.data?.available : false, /** The stored mode of the credential being reconnected; a reconnect reopens on it. */ reconnectAuthType, + /** A reconnected keyless Google credential's domain-wide delegation; a reconnect keeps it. */ + reconnectDelegation: setup.data?.reconnectDelegation, + /** A reconnected Microsoft app credential's national cloud; a reconnect reopens on it. */ + reconnectCloud: setup.data?.reconnectCloud, + /** + * Whether the reconnect fields above came back. False while they load, after a failed + * lookup, and for an organization credential, whose stored secret this lookup never reads. + */ + reconnectFieldsLoaded: workspaceScoped && setup.isSuccess, isReconnect: Boolean(credentialId), subjectChoice, setSubjectChoice, /** The new subject to submit, or undefined when keeping the stored one. */ trust: subjectChoice === 'new' ? trust : undefined, + /** The audience field's value; empty keeps the default. */ + audienceInput: audienceValue, + setAudienceInput, + /** The provider's default audience; undefined for Google, whose default is each credential's provider. */ + defaultAudience, + /** The audience typed into the field, unless it is blank, the default, or invalid. */ + customAudience, + /** Why the typed audience can't be used; setup snippets stay hidden until it can. */ + audienceError, + /** The audience tokens carry: the custom one, else the default; undefined while invalid. */ + audience: audienceError ? undefined : (customAudience ?? defaultAudience), /** Ready to submit a keyless configuration. */ - ready: subjectChoice === 'keep' ? Boolean(credentialId) : Boolean(trust), + ready: !audienceError && (subjectChoice === 'keep' ? Boolean(credentialId) : Boolean(trust)), error: setup.isError ? setup.error : null, } } @@ -96,15 +129,25 @@ function providerAuthType

    ( return modes.find((mode) => mode === value) } -/** The subject fields of a keyless config: a new subject with its proof, or none to keep the stored one. */ -export function keylessSubjectFields(setup: KeylessSetup) { - return setup.trust ? { subject: setup.trust.subject, subjectProof: setup.trust.subjectProof } : {} +/** + * The trust fields of a keyless config: a new subject with its proof (none keeps the + * stored one), and the custom audience when one is set (none means the default). + */ +export function keylessConfigFields(setup: KeylessSetup) { + return { + ...(setup.trust + ? { subject: setup.trust.subject, subjectProof: setup.trust.subjectProof } + : {}), + ...(setup.customAudience ? { audience: setup.customAudience } : {}), + } } interface KeylessTrustFieldsProps { setup: KeylessSetup - /** The credential's own audience, for a provider without a fixed one (Google). */ - audience?: string + /** The default audience, for a provider without a fixed one (Google: the credential's own provider). */ + defaultAudience?: string + /** Where the provider lists the audience it accepts, appended to the field's hint. */ + audienceHint: string /** Where in the provider's console the values below are pasted. */ pasteHint?: string } @@ -112,7 +155,8 @@ interface KeylessTrustFieldsProps { /** Reconnect choice plus the issuer, audience, and subject the customer configures. */ export function KeylessTrustFields({ setup, - audience = setup.trust?.audience, + defaultAudience = setup.defaultAudience, + audienceHint, pasteHint, }: KeylessTrustFieldsProps) { return ( @@ -150,14 +194,6 @@ export function KeylessTrustFields({ copyLabel='Copy issuer' hint={pasteHint} /> - {audience && ( - - )} )} + {(setup.trust || setup.isReconnect) && ( + + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx index 52df79fa08b..52d15dfb216 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx @@ -11,10 +11,15 @@ import { SecretInput, } from '@sim/emcn' import { resourceScopeFromOwner, resourceScopeKey } from '@/lib/core/resource-scope' +import { + MICROSOFT_CLOUD_IDS, + MICROSOFT_CLOUDS, + type MicrosoftCloudId, +} from '@/lib/credentials/microsoft-clouds' import { MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { KeylessTrustFields, - keylessSubjectFields, + keylessConfigFields, useKeylessSetup, useSavedConnectionSubmit, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection' @@ -43,6 +48,11 @@ const AUTH_TYPE_HINTS: Record = { const DOCS_URL = 'https://docs.sim.ai/integrations/microsoft-app-credentials' +const CLOUD_OPTIONS = MICROSOFT_CLOUD_IDS.map((id) => ({ + value: id, + label: MICROSOFT_CLOUDS[id].label, +})) + /** Saved Microsoft app credentials share the standard credential create/reconnect lifecycle. */ export function MicrosoftAppAccountModal(props: MicrosoftAppAccountModalProps) { if (!props.open) return null @@ -80,6 +90,7 @@ function MicrosoftAppAccountForm({ onClose: () => onOpenChange(false), }) const [selectedAuthType, setSelectedAuthType] = useState(null) + const [selectedCloud, setSelectedCloud] = useState(null) const [tenantId, setTenantId] = useState('') const [clientId, setClientId] = useState('') const [environmentUrl, setEnvironmentUrl] = useState('') @@ -97,8 +108,15 @@ function MicrosoftAppAccountForm({ ? 'federated' : 'client_secret' const authType: MicrosoftAuthType = selectedAuthType ?? defaultAuthType + // A reconnect opens on the credential's stored cloud. Until that is known (still loading, a + // failed lookup, or an organization credential) the cloud must be chosen, so a rotation never + // silently moves a national-cloud credential to the global cloud. + const storedCloudKnown = !credentialId || keyless.reconnectFieldsLoaded + const cloud: MicrosoftCloudId | undefined = + selectedCloud ?? (storedCloudKnown ? (keyless.reconnectCloud ?? 'global') : undefined) const disabled = isPending || + !cloud || !tenantId.trim() || !clientId.trim() || (authType === 'client_secret' && !clientSecret.trim()) || @@ -110,14 +128,16 @@ function MicrosoftAppAccountForm({ clearError() } - const buildConfig = (): Record => { + const buildConfig = (appCloud: MicrosoftCloudId): Record => { + const isGlobal = appCloud === 'global' const base = { authType, tenantId: tenantId.trim(), clientId: clientId.trim(), - ...(environmentUrl.trim() ? { environmentUrl: environmentUrl.trim() } : {}), + ...(isGlobal ? {} : { cloud: appCloud }), + ...(isGlobal && environmentUrl.trim() ? { environmentUrl: environmentUrl.trim() } : {}), } - if (authType === 'federated') return { ...base, ...keylessSubjectFields(keyless) } + if (authType === 'federated') return { ...base, ...keylessConfigFields(keyless) } if (authType === 'certificate') return { ...base, privateKey, certificate } return { ...base, clientSecret } } @@ -154,6 +174,19 @@ function MicrosoftAppAccountForm({ required hint={AUTH_TYPE_HINTS[authType]} /> + { + setSelectedCloud(value as MicrosoftCloudId) + clearError() + }} + options={CLOUD_OPTIONS} + placeholder='Choose the app’s cloud' + required + hint='The Microsoft cloud the app is registered in. Only Azure Data Explorer runs outside the global cloud.' + /> - + {cloud === 'global' && ( + + )} {authType === 'client_secret' && ( @@ -226,6 +261,7 @@ function MicrosoftAppAccountForm({ )} @@ -258,7 +294,9 @@ function MicrosoftAppAccountForm({ primaryAction={{ label: isPending ? 'Verifying...' : `${action} credential`, onClick: () => { - if (!disabled) void submit({ config: buildConfig(), displayName, description }) + if (!disabled && cloud) { + void submit({ config: buildConfig(cloud), displayName, description }) + } }, disabled, }} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx index 7bba14c85c6..3ba3690ecc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx @@ -9,6 +9,7 @@ import { ChipModalFooter, ChipModalHeader, ChipTextarea, + cn, SecretInput, } from '@sim/emcn' import { isApiClientError } from '@/lib/api/client/errors' @@ -25,11 +26,10 @@ import { import { type KeylessSetup, KeylessTrustFields, - keylessSubjectFields, + keylessConfigFields, useKeylessSetup, useSavedConnectionSubmit, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection' -import { TokenServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal' import { withBrandIcon } from '@/blocks/brand-icon' import { useCreateScopedCredential, @@ -49,18 +49,30 @@ interface SnowflakeAccountModalProps { onCreated?: (credentialId: string) => void } -type SnowflakeAuthType = 'workload_identity' | 'programmatic_access_token' +type SnowflakeAuthType = 'workload_identity' | 'key_pair' | 'programmatic_access_token' const SNOWFLAKE_DESCRIPTOR = getTokenServiceAccountDescriptor(SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID) const SNOWFLAKE_DOCS_URL = 'https://docs.sim.ai/integrations/snowflake-service-account' -const HOST_PATTERN = /^[a-z0-9][a-z0-9.-]*\.snowflakecomputing\.(com|cn)$/i +const HOST_PATTERN = /^[a-z0-9][a-z0-9_.-]*\.snowflakecomputing\.(com|cn)$/i const AUTH_TYPE_HINTS: Record = { workload_identity: 'No secret is stored. Sim signs a short-lived token your Snowflake service user trusts.', + key_pair: + 'Sim stores the private key and signs a short-lived token with it for each run. Assign the public key to the user in Snowflake first.', programmatic_access_token: 'Sim sends this token on every request.', } +/** Copy for the shared verification codes a key-pair save reports. */ +function keyPairErrorMessage(code: string): string | undefined { + switch (code) { + case 'invalid_credentials': + return 'Snowflake did not accept a token signed with this key. Check the user name, that its RSA_PUBLIC_KEY (or key pair) is this private key’s public key, and that a network policy allows Sim to reach the account.' + default: + return keylessErrorMessage(code) + } +} + /** Copy for the shared verification codes a keyless save reports. */ function keylessErrorMessage(code: string): string | undefined { switch (code) { @@ -75,14 +87,19 @@ function keylessErrorMessage(code: string): string | undefined { } } -/** The statement that creates a service user trusting only this credential's subject. */ -function buildCreateUserStatement(issuer: string, subject: string) { +/** + * The statement that creates a service user trusting only this credential's + * subject. Snowflake accepts the default `snowflakecomputing.com` audience with + * no `OIDC_AUDIENCE_LIST`; a custom audience must be listed there. + */ +function buildCreateUserStatement(issuer: string, subject: string, customAudience?: string) { return [ 'CREATE USER SIM_WORKFLOWS', ' WORKLOAD_IDENTITY = (', ' TYPE = OIDC', ` ISSUER = '${issuer}'`, ` SUBJECT = '${subject}'`, + ...(customAudience ? [` OIDC_AUDIENCE_LIST = ('${customAudience}')`] : []), ' )', ' TYPE = SERVICE;', ].join('\n') @@ -96,10 +113,10 @@ function normalizeHostInput(raw: string): string { } /** - * Snowflake connections: a programmatic access token, or keyless workload - * identity where this deployment can sign tokens. Where it cannot (or for an - * organization credential, which keyless never binds to), the standard - * token dialog is shown unchanged. + * Snowflake connections: a programmatic access token, a key pair, or keyless + * workload identity where this deployment can sign tokens. Where it cannot (or + * for an organization credential, which keyless never binds to), keyless is + * simply not offered. */ export function SnowflakeAccountModal(props: SnowflakeAccountModalProps) { if (!props.open) return null @@ -118,9 +135,6 @@ function SnowflakeAccountGate(props: SnowflakeAccountModalProps) { organizationId: props.organizationId, credentialId: props.credentialId, }) - if (keyless.available === false && SNOWFLAKE_DESCRIPTOR) { - return - } return } @@ -137,44 +151,49 @@ function SnowflakeAccountForm({ keyless, }: SnowflakeAccountModalProps & { keyless: KeylessSetup<'snowflake'> }) { const close = () => onOpenChange(false) - const keylessSubmit = useSavedConnectionSubmit({ - providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, - workspaceId, - organizationId, - credentialId, - onCreated, - onClose: close, - messageForCode: keylessErrorMessage, - }) const createCredential = useCreateScopedCredential() const updateCredential = useUpdateScopedCredential() const [tokenError, setTokenError] = useState(null) const [selectedAuthType, setSelectedAuthType] = useState(null) const [host, setHost] = useState('') const [token, setToken] = useState('') + const [user, setUser] = useState('') + const [privateKey, setPrivateKey] = useState('') const [displayName, setDisplayName] = useState(initialDisplayName ?? '') const [description, setDescription] = useState(initialDescription ?? '') - // A reconnect opens on the credential's stored mode, so it never switches auth by default; - // a new connection defaults to keyless wherever this deployment supports it. - const defaultAuthType: SnowflakeAuthType = credentialId - ? (keyless.reconnectAuthType ?? 'programmatic_access_token') + // A reconnect opens on the credential's stored mode, so it never switches auth by default. An + // organization credential's mode is not reported, so its reconnect starts with no mode chosen + // rather than a guess. A new connection defaults to keyless wherever this deployment supports it. + const defaultAuthType: SnowflakeAuthType | null = credentialId + ? (keyless.reconnectAuthType ?? null) : keyless.available ? 'workload_identity' : 'programmatic_access_token' - const authType: SnowflakeAuthType = selectedAuthType ?? defaultAuthType + const authType = selectedAuthType ?? defaultAuthType + const connectionSubmit = useSavedConnectionSubmit({ + providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, + workspaceId, + organizationId, + credentialId, + onCreated, + onClose: close, + messageForCode: authType === 'key_pair' ? keyPairErrorMessage : keylessErrorMessage, + }) const normalizedHost = normalizeHostInput(host) const isPending = - keylessSubmit.isPending || createCredential.isPending || updateCredential.isPending + connectionSubmit.isPending || createCredential.isPending || updateCredential.isPending const disabled = isPending || + !authType || !normalizedHost || (authType === 'programmatic_access_token' && !token.trim()) || + (authType === 'key_pair' && (!user.trim() || !privateKey.trim())) || (authType === 'workload_identity' && !keyless.ready) - const error = authType === 'workload_identity' ? keylessSubmit.error : tokenError + const error = authType === 'programmatic_access_token' ? tokenError : connectionSubmit.error const clearErrors = () => { - keylessSubmit.clearError() + connectionSubmit.clearError() setTokenError(null) } const edit = (setter: (value: string) => void) => (value: string) => { @@ -220,11 +239,24 @@ function SnowflakeAccountForm({ void submitToken() return } - void keylessSubmit.submit({ + if (authType === 'key_pair') { + void connectionSubmit.submit({ + config: { + authType: 'key_pair', + host: normalizedHost, + user: user.trim(), + privateKey: privateKey.trim(), + }, + displayName, + description, + }) + return + } + void connectionSubmit.submit({ config: { authType: 'workload_identity', host: normalizedHost, - ...keylessSubjectFields(keyless), + ...keylessConfigFields(keyless), }, displayName, description, @@ -232,14 +264,19 @@ function SnowflakeAccountForm({ } const createUserStatement = - authType === 'workload_identity' && keyless.trust - ? buildCreateUserStatement(keyless.trust.issuer, keyless.trust.subject) + authType === 'workload_identity' && keyless.trust && !keyless.audienceError + ? buildCreateUserStatement( + keyless.trust.issuer, + keyless.trust.subject, + keyless.customAudience + ) : null const action = credentialId ? 'Reconnect' : 'Add' const authOptions = [ ...(keyless.available ? [{ value: 'workload_identity', label: 'Service user (keyless, via Sim OIDC)' }] : []), + { value: 'key_pair', label: 'Key pair (RSA private key)' }, { value: 'programmatic_access_token', label: 'Programmatic access token' }, ] @@ -252,7 +289,7 @@ function SnowflakeAccountForm({ { setSelectedAuthType(value as SnowflakeAuthType) clearErrors() @@ -260,7 +297,7 @@ function SnowflakeAccountForm({ options={authOptions} placeholder='Choose how Sim signs in to Snowflake' required - hint={AUTH_TYPE_HINTS[authType]} + hint={authType ? AUTH_TYPE_HINTS[authType] : undefined} /> @@ -292,7 +329,51 @@ function SnowflakeAccountForm({ )} - {authType === 'workload_identity' && } + {authType === 'key_pair' && ( + <> + + + {(aria) => ( + edit(setPrivateKey)(event.target.value)} + placeholder='-----BEGIN PRIVATE KEY-----' + className={cn( + 'min-h-[120px] font-mono', + privateKey && '[&:not(:focus)]:[-webkit-text-security:disc]' + )} + spellCheck={false} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + )} + + + )} + + {authType === 'workload_identity' && ( + + )} {createUserStatement && ( (null) + const [selectedAuthMethod, setSelectedAuthMethod] = + useState(null) /** * A reconnect cannot pre-select the mode: the stored one lives inside the encrypted secret and * is never returned to the client, so the admin restates it while retyping the secret. Until * then neither mode's fields show, so a reconnect never switches modes by default. */ const mustRestateAuthMethod = Boolean(clientCredentials && credentialId && !selectedAuthMethod) - const authMethod: TokenAuthMethod | null = + const authMethod: TokenServiceAccountAuthMethod | null = selectedAuthMethod ?? (mustRestateAuthMethod ? null @@ -115,10 +118,29 @@ function TokenServiceAccountModalForm({ ? CLIENT_CREDENTIALS_AUTH_METHOD : 'token') const usesClientCredentials = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD + /** A field that belongs to one sign-in mode (ServiceNow's username) shows once that mode is chosen. */ + const shownField = (id: TokenServiceAccountField['id']) => + descriptor.fields.find( + (field) => + field.id === id && + (authMethod + ? tokenFieldBelongsToAuthMethod(field, authMethod) + : !field.requiredForAuthMethods) + ) + const tokenField = descriptor.fields.find((field) => field.id === 'apiToken') + const domainField = shownField('domain') + const workspaceField = shownField('providerWorkspaceId') + /** + * A new connection starts on the domain's default (the region or host it had before the field + * existed). A reconnect starts blank, which keeps the stored value: the server carries it + * forward, since the stored one is never returned to the client. + */ + const initialDomain = credentialId ? '' : (domainField?.defaultValue ?? '') + const [apiToken, setApiToken] = useState('') const [clientId, setClientId] = useState('') const [clientSecret, setClientSecret] = useState('') - const [domain, setDomain] = useState('') + const [domain, setDomain] = useState(initialDomain) const [providerWorkspaceId, setProviderWorkspaceId] = useState('') const [displayName, setDisplayName] = useState(initialDisplayName ?? '') const [description, setDescription] = useState(initialDescription ?? '') @@ -127,19 +149,20 @@ function TokenServiceAccountModalForm({ const createCredential = useCreateScopedCredential() const updateCredential = useUpdateScopedCredential() - const tokenField = descriptor.fields.find((field) => field.id === 'apiToken') - const domainField = descriptor.fields.find((field) => field.id === 'domain') - const workspaceField = descriptor.fields.find((field) => field.id === 'providerWorkspaceId') - const trimmedToken = apiToken.trim() const normalizedDomain = domainField?.url ? domain.trim() : normalizeDomainInput(domain) + const trimmedWorkspaceId = providerWorkspaceId.trim() const isPending = createCredential.isPending || updateCredential.isPending || submitPending === true + const isFieldRequired = (field: TokenServiceAccountField): boolean => + isTokenServiceAccountFieldRequired(field) || + (field.requiredForDomain !== undefined && + normalizedDomain.toLowerCase() === field.requiredForDomain) const isDisabled = !authMethod || (usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) || - (Boolean(domainField) && !normalizedDomain) || - (Boolean(workspaceField) && !providerWorkspaceId.trim()) || + Boolean(domainField && isFieldRequired(domainField) && !normalizedDomain) || + Boolean(workspaceField && isFieldRequired(workspaceField) && !trimmedWorkspaceId) || isPending const hintFor = (field: TokenServiceAccountField, value: string): string | undefined => { @@ -147,6 +170,48 @@ function TokenServiceAccountModalForm({ return field.hintPattern.test(value) ? undefined : field.hintMessage } + const renderConnectionField = ( + field: TokenServiceAccountField, + value: string, + setValue: (value: string) => void, + formatError?: string + ) => { + const onChange = (next: string) => { + setValue(next) + if (error) setError(null) + } + const placeholder = + credentialId && field.defaultValue !== undefined + ? `Keep the current ${field.label.toLowerCase()}` + : field.placeholder + if (field.options) { + return ( + + ) + } + return ( + + ) + } + const handleSubmit = async () => { setError(null) if (isDisabled) return @@ -159,8 +224,10 @@ function TokenServiceAccountModalForm({ clientSecret: clientSecret.trim(), } : { apiToken: tokenField?.preserveWhitespace ? apiToken : trimmedToken }), - ...(workspaceField ? { providerWorkspaceId: providerWorkspaceId.trim() } : {}), - ...(domainField ? { domain: normalizedDomain } : {}), + ...(workspaceField && trimmedWorkspaceId + ? { providerWorkspaceId: trimmedWorkspaceId } + : {}), + ...(domainField && normalizedDomain ? { domain: normalizedDomain } : {}), } if (onSubmitCredentials) { const savedId = await onSubmitCredentials({ @@ -223,7 +290,7 @@ function TokenServiceAccountModalForm({ title='Authentication' value={authMethod ?? undefined} onChange={(value) => { - setSelectedAuthMethod(value as TokenAuthMethod) + setSelectedAuthMethod(value as TokenServiceAccountAuthMethod) if (error) setError(null) }} options={[ @@ -258,49 +325,54 @@ function TokenServiceAccountModalForm({ required hint={hintFor(tokenField, trimmedToken) ?? descriptor.helpText} > - { - setApiToken(value) - if (error) setError(null) - }} - placeholder={tokenField.placeholder} - name={`${descriptor.providerId}_api_token`} - autoComplete='new-password' - autoCorrect='off' - autoCapitalize='off' - data-lpignore='true' - data-form-type='other' - /> + {tokenField.multiline ? ( + (aria) => ( + { + setApiToken(event.target.value) + if (error) setError(null) + }} + placeholder={tokenField.placeholder} + className='min-h-[120px] font-mono' + // Spell-check and autofill would ship a pasted private key to third parties. + spellCheck={false} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + ) + ) : ( + { + setApiToken(value) + if (error) setError(null) + }} + placeholder={tokenField.placeholder} + name={`${descriptor.providerId}_api_token`} + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + )} )} - {workspaceField && ( - - )} - {domainField && ( - { - setDomain(value) - if (error) setError(null) - }} - placeholder={domainField.placeholder} - autoComplete='off' - required - error={hintFor(domainField, normalizedDomain)} - /> - )} + {workspaceField && + renderConnectionField(workspaceField, providerWorkspaceId, setProviderWorkspaceId)} + {domainField && + renderConnectionField( + domainField, + domain, + setDomain, + hintFor(domainField, normalizedDomain) + )} )} + {credential.expiresAt && ( + + + {formatDateTime(new Date(credential.expiresAt), 'UTC')} · {expiryLabel} + + + )} { + root.render() + }) +} + +describe('useSavedTokenExpiryLabel', () => { + beforeEach(() => { + vi.useFakeTimers({ now: NOW }) + container = document.createElement('div') + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + vi.useRealTimers() + }) + + it('reads expired once the token expires while the page stays open', () => { + mount(new Date(NOW + 60_000).toISOString()) + expect(label).toBe('expires in 1 day') + act(() => { + vi.advanceTimersByTime(60_000) + }) + expect(label).toBe('expired') + }) + + it('counts the days down while the page stays open', () => { + mount(new Date(NOW + 2 * DAY_MS).toISOString()) + expect(label).toBe('expires in 2 days') + act(() => { + vi.advanceTimersByTime(DAY_MS + 1) + }) + expect(label).toBe('expires in 1 day') + }) + + it('times a reconnected token from the moment it arrives, not from when the page opened', () => { + mount(new Date(NOW + 3 * DAY_MS).toISOString()) + act(() => { + vi.advanceTimersByTime(6 * 60 * 60 * 1000) + }) + mount(new Date(Date.now() + 60 * 60 * 1000).toISOString()) + expect(label).toBe('expires in 1 day') + act(() => { + vi.advanceTimersByTime(60 * 60 * 1000) + }) + expect(label).toBe('expired') + }) + + it('counts a token that gains an expiry after the page has been open for days from now', () => { + mount(undefined) + act(() => { + vi.advanceTimersByTime(3 * DAY_MS) + }) + mount(new Date(Date.now() + 60 * 60 * 1000).toISOString()) + expect(label).toBe('expires in 1 day') + }) + + it('has no caption for a token that states no expiry', () => { + mount(undefined) + expect(label).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label.ts b/apps/sim/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label.ts new file mode 100644 index 00000000000..cc4e40d9f23 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react' +import { + msUntilSavedTokenExpiryLabelChanges, + savedTokenExpiryLabel, +} from '@/lib/credentials/expiry-label' + +/** + * The caption for a saved token's expiry, kept current while the page stays open: it re-reads the + * clock when the expiry changes (a reconnect), when the day count drops, and when the token + * expires. `undefined` when the token states no readable expiry. + */ +export function useSavedTokenExpiryLabel(expiresAt: string | undefined): string | undefined { + const expiresAtMs = expiresAt ? new Date(expiresAt).getTime() : Number.NaN + const [clock, setClock] = useState(() => ({ expiresAtMs, now: Date.now() })) + if (!Object.is(clock.expiresAtMs, expiresAtMs)) { + setClock({ expiresAtMs, now: Date.now() }) + } + const { now } = clock + + useEffect(() => { + const untilChange = msUntilSavedTokenExpiryLabelChanges(new Date(expiresAtMs), now) + if (untilChange === null) return + const timer = setTimeout( + () => setClock((current) => ({ ...current, now: Date.now() })), + untilChange + ) + return () => clearTimeout(timer) + }, [expiresAtMs, now]) + + return Number.isFinite(expiresAtMs) + ? savedTokenExpiryLabel(new Date(expiresAtMs), now) + : undefined +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 7817b234ae4..e979b82658c 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -26,6 +26,7 @@ import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components/ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' +import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label' import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' import { ALL_CATEGORY, @@ -142,15 +143,32 @@ interface ConnectedItemProps { name: string description: string icon: ComponentType<{ className?: string }> + /** ISO time the saved token stops working, when its vendor stated one. */ + expiresAt?: string } -function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) { +function ConnectedItem({ + href, + blockType, + name, + description, + icon: Icon, + expiresAt, +}: ConnectedItemProps) { + const expiryLabel = useSavedTokenExpiryLabel(expiresAt) return ( } title={name} description={description} + badge={ + expiryLabel ? ( + + {expiryLabel} + + ) : undefined + } href={href} clickLabel={`Open ${name}`} navigable @@ -439,6 +457,7 @@ function IntegrationsContent() { name={item.name} description={item.description} icon={item.icon} + expiresAt={item.credential.expiresAt} /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index b14b5b9aac3..5e57cb57234 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -106,6 +106,7 @@ import { kbDocumentSortParams, } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' import { canDeleteKnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/permissions' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { BrandIcon } from '@/blocks/brand-icon' @@ -420,6 +421,7 @@ export function KnowledgeBase({ const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false) const [documentForTagsId, setDocumentForTagsId] = useState(null) const showAddConnectorModal = addConnectorType != null + const savedCredentialSetupEnabled = useFeatureFlag('knowledge-connector-saved-credentials') const updateAddConnectorParam = useCallback( (value: string | null) => { void setAddConnectorType(value, { history: 'replace', scroll: false }) @@ -1472,6 +1474,7 @@ export function KnowledgeBase({ knowledgeBaseId={id} initialConnectorType={addConnectorType || undefined} isSearchIndex={knowledgeBase?.isSearchIndex} + savedCredentialSetupEnabled={savedCredentialSetupEnabled} /> )} @@ -1513,6 +1516,7 @@ export function KnowledgeBase({ isLoading={isLoadingConnectors} canEdit={userPermissions.canEdit} className='mt-0' + savedCredentialSetupEnabled={savedCredentialSetupEnabled} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 9cc992c2cf4..2950343d65a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -63,7 +63,11 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { withBrandIcon } from '@/blocks/brand-icon' -import { getConnectorApiKeyConfig, isConnectorCredentialTypeAllowed } from '@/connectors/auth' +import { + getConnectorApiKeyConfig, + isConnectorCredentialTypeAllowed, + isConnectorFieldForAuth, +} from '@/connectors/auth' import { GitHubInstallationConnectionField } from '@/connectors/github/installation-connection-field' import { GitLabPermissionTabs, @@ -109,6 +113,8 @@ interface AddConnectorModalProps { initialSyncIntervalMinutes?: number onCreated?: (connectorType: string, connector: ConnectorData) => void setupDraftKey?: string + /** Offers a key connector's saved-credential alternative, such as a saved AWS credential for S3. */ + savedCredentialSetupEnabled?: boolean } type Step = 'select-type' | 'configure' @@ -125,6 +131,7 @@ export function AddConnectorModal({ lockedAccessMode, initialSyncIntervalMinutes = 1440, onCreated, + savedCredentialSetupEnabled = false, setupDraftKey, scope: explicitScope, }: AddConnectorModalProps) { @@ -213,8 +220,14 @@ export function AddConnectorModal({ const showGitLabPermissions = selectedType === 'gitlab' && access.accessMode === 'admin' const isMembersMode = access.accessMode === 'members' const apiKeyConfig = connectorConfig ? getConnectorApiKeyConfig(connectorConfig.auth) : undefined + const savedCredentialConfig = + savedCredentialSetupEnabled && connectorConfig?.auth.mode === 'apiKey' + ? connectorConfig.auth.savedCredential + : undefined const isApiKeyMode = - connectorConfig?.auth.mode === 'apiKey' || Boolean(apiKeyConfig && !isMembersMode && useApiKey) + connectorConfig?.auth.mode === 'apiKey' + ? !savedCredentialConfig || useApiKey + : Boolean(apiKeyConfig && !isMembersMode && useApiKey) const { integrationAvailability, oauthServiceAvailability, @@ -249,10 +262,13 @@ export function AddConnectorModal({ /** True when the connector declares its key optional (public sources need none). */ const isApiKeyOptional = connectorConfig?.auth.mode === 'apiKey' && connectorConfig.auth.optional === true - const connectorProviderId = + const credentialServiceId = connectorConfig?.auth.mode === 'oauth' - ? (getProviderIdFromServiceId(connectorConfig.auth.provider) as OAuthProvider) - : null + ? connectorConfig.auth.provider + : savedCredentialConfig?.provider + const connectorProviderId = credentialServiceId + ? (getProviderIdFromServiceId(credentialServiceId) as OAuthProvider) + : null const serviceAccountProviderId = connectorProviderId ? getServiceAccountProviderForProviderId(connectorProviderId) @@ -263,7 +279,7 @@ export function AddConnectorModal({ !isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth') const serviceAccountTarget = useServiceAccountConnectTarget({ serviceAccountProviderId: - isSearchIndex || requiresServiceAccount + isSearchIndex || requiresServiceAccount || savedCredentialConfig ? asServiceAccountProviderId(serviceAccountProviderId) : undefined, serviceName: connectorConfig?.name, @@ -303,7 +319,7 @@ export function AddConnectorModal({ isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, credential.type) ) const canConnectOAuth = - connectorConfig && + connectorConfig?.auth.mode === 'oauth' && isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth') const effectiveCredentialId = selectedCredentialId && credentials.some((credential) => credential.id === selectedCredentialId) @@ -367,6 +383,7 @@ export function AddConnectorModal({ ) const isFieldVisible = (field: ConnectorConfigField) => isConfigFieldVisible(field) && + isConnectorFieldForAuth(field, isApiKeyMode) && (connectorConfig?.auth.mode !== 'oauth' || connectorConfig.auth.serviceAccountSubjectFieldId !== field.id || indexingCredential?.type === 'service_account') @@ -541,6 +558,13 @@ export function AddConnectorModal({ ) ) + /** Key-only settings typed before switching to a saved credential are never sent with it. */ + const keyOnlyFieldIds = new Set( + (connectorConfig?.configFields ?? []) + .filter((field) => !isConnectorFieldForAuth(field, isApiKeyMode)) + .map((field) => field.canonicalParamId ?? field.id) + ) + const handleSubmit = () => { if (!selectedType || !canSubmit) return @@ -548,7 +572,7 @@ export function AddConnectorModal({ const resolvedConfig: Record = {} for (const [key, value] of Object.entries(resolveSourceConfig())) { - if (hiddenCapFieldIds.has(key)) continue + if (hiddenCapFieldIds.has(key) || keyOnlyFieldIds.has(key)) continue if (Array.isArray(value)) { if (value.length > 0) resolvedConfig[key] = value } else if (typeof value === 'string') { @@ -714,23 +738,28 @@ export function AddConnectorModal({ {!slackSetupRequired && ( <> - {connectorConfig.auth.mode === 'oauth' && apiKeyConfig && !isMembersMode && ( - - { - setUseApiKey(value === 'apiKey') - setApiKeyValue('') - setSelectedCredentialId(null) - }} - /> - - )} + {apiKeyConfig && + (savedCredentialConfig || + (connectorConfig.auth.mode === 'oauth' && !isMembersMode)) && ( + + { + setUseApiKey(value === 'apiKey') + setApiKeyValue('') + setSelectedCredentialId(null) + }} + /> + + )} {isApiKeyMode ? ( {credentialsError && rawCredentials.length === 0 ? ( @@ -801,7 +830,9 @@ export function AddConnectorModal({ placeholder={ canConnectOAuth ? `Select ${connectorConfig.name} account` - : 'Select a service account' + : savedCredentialConfig + ? 'Select a credential' + : 'Select a service account' } isLoading={credentialsLoading || isIntegrationAvailabilityLoading} disabled={isCreating || !isIntegrationAvailabilityReady} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index 5eec846a5e9..b659418ae9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -31,6 +31,7 @@ interface ConnectorsSectionProps { isLoading: boolean canEdit: boolean className?: string + savedCredentialSetupEnabled?: boolean } const STATUS_CONFIG = { @@ -51,6 +52,7 @@ export function ConnectorsSection({ isLoading, canEdit, className, + savedCredentialSetupEnabled = false, }: ConnectorsSectionProps) { const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) const [editingConnector, setEditingConnector] = useState(null) @@ -84,6 +86,7 @@ export function ConnectorsSection({ knowledgeBaseId={knowledgeBaseId} isSearchIndex={isSearchIndex} connector={editingConnector} + savedCredentialSetupEnabled={savedCredentialSetupEnabled} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx index 3ea7a10d590..ea9b30290ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx @@ -44,7 +44,7 @@ import type { ConfigFieldValue, } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { isConnectorCredentialTypeAllowed } from '@/connectors/auth' +import { getConnectorCredentialService, isConnectorCredentialTypeAllowed } from '@/connectors/auth' import { GitHubInstallationConnectionField } from '@/connectors/github/installation-connection-field' import { GitLabPermissionTabs, @@ -162,10 +162,14 @@ export function ConnectorSettingsFields({ const connectorConfig = liveSearchSourceMeta(originalConfig, Boolean(liveSearch), { githubInstallation: usesGitHubInstallation && scope.kind === 'organization', }) - const providerId = - connectorConfig?.auth.mode === 'oauth' - ? (getProviderIdFromServiceId(connectorConfig.auth.provider) as OAuthProvider) - : null + const credentialServiceId = connectorConfig + ? getConnectorCredentialService(connectorConfig.auth) + : undefined + const providerId = credentialServiceId + ? (getProviderIdFromServiceId(credentialServiceId) as OAuthProvider) + : null + const savedCredentialConfig = + connectorConfig?.auth.mode === 'apiKey' ? connectorConfig.auth.savedCredential : undefined const syncsPerMember = access.accessMode === 'members' const isGitHubInstallationSource = usesGitHubInstallation && @@ -182,7 +186,7 @@ export function ConnectorSettingsFields({ : undefined const serviceAccountTarget = useServiceAccountConnectTarget({ serviceAccountProviderId: - isSearchIndex || requiresServiceAccount + isSearchIndex || requiresServiceAccount || savedCredentialConfig ? asServiceAccountProviderId(serviceAccountProviderId) : undefined, serviceName: connectorConfig?.name, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 4c6b9471546..045814e9628 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -26,6 +26,7 @@ interface EditConnectorModalProps { knowledgeBaseId: string isSearchIndex?: boolean connector: ConnectorData + savedCredentialSetupEnabled?: boolean } export function EditConnectorModal({ @@ -35,6 +36,7 @@ export function EditConnectorModal({ isSearchIndex = false, connector, scope, + savedCredentialSetupEnabled = false, }: EditConnectorModalProps) { const [activeTab, setActiveTab] = useState('settings') const [documentFilter, setDocumentFilter] = useState('active') @@ -44,6 +46,7 @@ export function EditConnectorModal({ isSearchIndex, connector, onSaved: () => onOpenChange(false), + savedCredentialSetupEnabled, }) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts index 300366d0e7a..8cad515af54 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts @@ -6,6 +6,7 @@ import { isEqual } from 'es-toolkit' import type { UpdateConnectorBody } from '@/lib/api/contracts/knowledge/connectors' import type { ResourceScope } from '@/lib/core/resource-scope' import { isContentEngineAccessMode } from '@/lib/knowledge/connectors/access-modes' +import { CREDENTIAL_REMOVED_SYNC_ERROR } from '@/lib/knowledge/connectors/sync-limits' import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors' import { liveSearchSourceMeta } from '@/lib/sim-search/live/source-settings' import { readSourceSelectionLabels, SOURCE_LABELS_KEY } from '@/lib/sim-search/source-identity' @@ -20,9 +21,10 @@ import { useConnectorConfigFields, } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorScope } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope' -import { isConnectorCredentialTypeAllowed } from '@/connectors/auth' +import { isConnectorCredentialTypeAllowed, isConnectorFieldForAuth } from '@/connectors/auth' import { useGitLabPermissionForm } from '@/connectors/gitlab/permission-config/use-permission-form' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import type { ConnectorConfigField } from '@/connectors/types' import { type ConnectorData, isConnectorSyncingOrPending, @@ -125,6 +127,8 @@ interface UseConnectorSettingsFormOptions { connector: ConnectorData onSaved: (connector: ConnectorData) => void syncing?: boolean + /** Whether a key connector's saved credential may be replaced; the server refuses it while setup is off. */ + savedCredentialSetupEnabled?: boolean } /** Editable connector settings shared by the source page and knowledge-base modal. */ @@ -135,6 +139,7 @@ export function useConnectorSettingsForm({ connector, onSaved, syncing = isConnectorSyncingOrPending(connector), + savedCredentialSetupEnabled = false, }: UseConnectorSettingsFormOptions) { const liveSearch = isSearchIndex const connectorConfig = liveSearchSourceMeta( @@ -232,6 +237,18 @@ export function useConnectorSettingsForm({ initialSelectionLabels, }) + /** + * A key connector that syncs with its saved credential, or did until the credential was + * removed: its credential stays replaceable here while saved-credential setup is enabled, + * and its key-only settings do not apply. + */ + const usesSavedCredential = + connectorConfig?.auth.mode === 'apiKey' && + Boolean(connectorConfig.auth.savedCredential) && + (Boolean(connector.credentialId) || connector.lastSyncError === CREDENTIAL_REMOVED_SYNC_ERROR) + const isAuthFieldVisible = (field: ConnectorConfigField) => + isFieldVisible(field) && isConnectorFieldForAuth(field, !usesSavedCredential) + const { scope, canAdmin, memberAccessAvailable, mirroredAccessAvailable, hasMaxAccess } = useConnectorScope(explicitScope) const { mutate: updateConnector, isPending: isSavingSettings } = useUpdateConnector() @@ -280,10 +297,11 @@ export function useConnectorSettingsForm({ accessDirty && !accessModeChanged && isContentEngineAccessMode(access.accessMode) /** Exposes credential selection for mode changes and administrator credential recovery. */ const needsWorkspaceCredential = - connectorConfig?.auth.mode === 'oauth' && - isContentEngineAccessMode(access.accessMode) && - (persistedAccess.accessMode === 'members' || - !isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth')) + (usesSavedCredential && savedCredentialSetupEnabled) || + (connectorConfig?.auth.mode === 'oauth' && + isContentEngineAccessMode(access.accessMode) && + (persistedAccess.accessMode === 'members' || + !isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth'))) const missingAdminField = accessDirty && access.accessMode === 'admin' ? connectorConfig?.configFields.find((field) => { @@ -316,7 +334,7 @@ export function useConnectorSettingsForm({ const settingsComplete = connectorConfig?.configFields.every( (field) => !isConnectorFieldRequired(field, connectorConfig, persistedAccess.accessMode) || - !isFieldVisible(field) || + !isAuthFieldVisible(field) || hiddenCapFieldIds.has(field.id) || isFieldPopulated(field) ) @@ -526,7 +544,7 @@ export function useConnectorSettingsForm({ canonicalModes, onToggleCanonicalMode: toggleCanonicalMode, onFieldChange: handleFieldChange, - isFieldVisible: (field) => isFieldVisible(field) && !hiddenCapFieldIds.has(field.id), + isFieldVisible: (field) => isAuthFieldVisible(field) && !hiddenCapFieldIds.has(field.id), syncInterval, setSyncInterval, hasMaxAccess, diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index fb9ac90703f..016a072e5e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -7,12 +7,12 @@ import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isChangelogEnabled } from '@/lib/changelog/feature-flag' -import { isComputerUseAvailable } from '@/lib/computer-use/availability.server' import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { hasDesktopBackgroundExecutor, isDesktopBackgroundExecutorAvailable, } from '@/lib/desktop/executor/availability' +import { isConnectorSavedCredentialSetupEnabled } from '@/lib/knowledge/connectors/saved-credential-rollout' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry' import { isProjectsEnabled } from '@/lib/projects/rollout.server' @@ -134,9 +134,9 @@ async function WorkspaceContent({ workflowTestsEnabled, changelogEnabled, desktopExecutorRegistered, - computerUseEnabled, projectsEnabled, simLinkOrganizationId, + connectorSavedCredentialsEnabled, ] = await Promise.all([ cookies(), orgSettings, @@ -154,9 +154,9 @@ async function WorkspaceContent({ isWorkflowTestsEnabled(hostContext.hostOrganizationId), isChangelogEnabled(hostContext.hostOrganizationId), hasDesktopBackgroundExecutor(session.user.id), - isComputerUseAvailable(hostContext.hostOrganizationId), isProjectsEnabled(), resolveSimLinkOrganization(principal), + isConnectorSavedCredentialSetupEnabled(), prefetchWorkspaceAccess(queryClient, workspaceId, principal), prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) @@ -170,9 +170,9 @@ async function WorkspaceContent({ 'workflow-tests': workflowTestsEnabled, changelog: changelogEnabled, 'mothership-model-selector': modelSelectorEnabled, - 'mothership-computer-use': computerUseEnabled, 'mothership-plan-mode': planModeEnabled, projects: projectsEnabled, + 'knowledge-connector-saved-credentials': connectorSavedCredentialsEnabled, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx index f33fd8f230f..02ba4adb864 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -8,9 +8,9 @@ export interface WorkspaceFeatureFlags { changelog: boolean 'mothership-model-selector': boolean 'mothership-plan-mode': boolean - 'mothership-computer-use'?: boolean 'mothership-memory-spaces'?: boolean projects?: boolean + 'knowledge-connector-saved-credentials'?: boolean } const FeatureFlagsContext = createContext(null) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx index 1e22dc60339..6c03a168096 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx @@ -8,9 +8,10 @@ import type { import { toast } from '@sim/emcn' import { createDeferred } from '@sim/testing/helpers/deferred' import { libDesktopMock, libDesktopMockFns } from '@sim/testing/mocks/lib-desktop.mock' +import { nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { ComputerUseSettings } from '@/app/workspace/[workspaceId]/settings/components/desktop/computer-use' const native = vi.hoisted(() => ({ @@ -20,6 +21,20 @@ const native = vi.hoisted(() => ({ cancel: vi.fn<() => Promise>(), onActivity: vi.fn<(listener: (activity: ComputerUseActivity | null) => void) => () => void>(), })) +const permission = vi.hoisted(() => ({ + config: null as Record | null, + ready: true, +})) +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + useUserPermissionConfig: () => + permission.ready + ? { isSuccess: true, data: { config: permission.config } } + : { isSuccess: false, data: undefined }, +})) +vi.mock( + 'next/navigation', + async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock +) vi.mock('@/lib/desktop', () => libDesktopMock) const initialPermissions = [ @@ -29,12 +44,13 @@ const initialPermissions = [ let permissions: ComputerUseAppPermission[] let activity: ComputerUseActivity | null let listeners: Set<(activity: ComputerUseActivity | null) => void> -let enabled: boolean let root: Root let container: HTMLDivElement beforeEach(() => { - enabled = true + permission.config = null + permission.ready = true + nextNavigationMockFns.mockUseParams.mockReturnValue({ workspaceId: 'ws-1' }) permissions = [...initialPermissions] activity = null listeners = new Set() @@ -69,52 +85,25 @@ afterEach(async () => { }) function renderSettings() { - root.render( - - - - ) + root.render() } -it('keeps settings dormant while rollout is off, including focus and activity changes', async () => { - enabled = false - await act(async () => renderSettings()) - await act(async () => window.dispatchEvent(new Event('focus'))) - expect(native.getStatus).not.toHaveBeenCalled() - expect(native.listAppPermissions).not.toHaveBeenCalled() - expect(container.textContent).toBe('') +it.each(['disableComputerUse', 'hideCopilot'])( + 'withholds the device controls when the permission group sets %s', + async (key) => { + permission.config = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: true } + await act(async () => renderSettings()) + expect(appRow('com.example.First')).toBeUndefined() + expect(container.querySelector('[role="switch"]')).toBeNull() + } +) - enabled = true +it('withholds the device controls until the permission read succeeds', async () => { + permission.ready = false await act(async () => renderSettings()) - expect(native.getStatus).toHaveBeenCalled() - expect(appRow('com.example.First')).toBeDefined() - await act(async () => { - activity = { toolCallId: 'in-flight', scopeId: 'chat', action: 'click', startedAt: Date.now() } - for (const listener of listeners) listener(activity) - }) - enabled = false - await act(async () => renderSettings()) - native.getStatus.mockClear() - native.listAppPermissions.mockClear() - await act(async () => { - window.dispatchEvent(new Event('focus')) - for (const listener of listeners) listener(activity) - }) - expect(native.getStatus).not.toHaveBeenCalled() + expect(appRow('com.example.First')).toBeUndefined() + expect(container.querySelector('[role="switch"]')).toBeNull() expect(native.listAppPermissions).not.toHaveBeenCalled() - const stop = container.querySelector('button') - expect(stop?.textContent).toContain('Stop') - await act(async () => stop?.click()) - expect(native.cancel).toHaveBeenCalledOnce() }) function appRow(bundleId: string) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx index 4d38a5241ec..af632d25f50 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx @@ -3,17 +3,26 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { ComputerUseAppPermission } from '@sim/desktop-bridge' import { Chip, ChipSwitch, Label, toast } from '@sim/emcn' +import { useParams } from 'next/navigation' import { ComputerUseActivity } from '@/components/computer-use/activity' import { getDesktopBridge } from '@/lib/desktop' -import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { useComputerUseStatus } from '@/hooks/use-computer-use-status' +/** The activity stays visible so an in-flight action can be stopped; the device controls need the permission group's consent. */ export function ComputerUseSettings() { + const { workspaceId } = useParams<{ workspaceId: string }>() + const permission = useUserPermissionConfig(workspaceId) + const allowed = + permission.isSuccess && + (permission.data.config === null || + !CAPABILITY_RULES['computer_use.use'].deniedBy(permission.data.config)) return ( <> - + {allowed && } ) } @@ -21,7 +30,6 @@ export function ComputerUseSettings() { function ComputerUseSettingsControls() { const requestVersion = useRef(0) const bridge = getDesktopBridge()?.computerUse - const enabled = useFeatureFlag('mothership-computer-use') const { status, setStatus, refresh, error } = useComputerUseStatus() const [apps, setApps] = useState([]) const [pending, setPending] = useState(false) @@ -36,13 +44,12 @@ function ComputerUseSettingsControls() { } }, [bridge]) useEffect(() => { - if (!enabled) return void refreshApps() return () => { requestVersion.current += 1 } - }, [enabled, refreshApps, status?.activeAction]) - if (!bridge || !enabled || status?.supported === false) return null + }, [refreshApps, status?.activeAction]) + if (!bridge || status?.supported === false) return null const update = async (action: () => Promise) => { setPending(true) try { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx index 5d4ee1e2bf5..c847090771d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx @@ -8,6 +8,16 @@ import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' +const permission = vi.hoisted(() => ({ + config: null as Record | null, + ready: true, +})) +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + useUserPermissionConfig: () => + permission.ready + ? { isSuccess: true, data: { config: permission.config } } + : { isSuccess: false, data: undefined }, +})) vi.mock('@sim/emcn', () => ({ ...emcnMock, Label: ({ children, htmlFor }: { children?: ReactNode; htmlFor?: string }) => ( @@ -66,7 +76,6 @@ async function render(device: { deviceId: string } | null) { changelog: false, 'mothership-model-selector': false, 'mothership-plan-mode': false, - 'mothership-computer-use': false, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 3061fb6958f..72f647676b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -28,9 +28,12 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow import { BrandIcon } from '@/blocks/brand-icon' import type { SubBlockConfig } from '@/blocks/types' import { useWorkspaceCredential, useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { useIntegrationAvailability } from '@/hooks/queries/integration-availability' import { useOAuthCredentials } from '@/hooks/queries/oauth/credentials' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' +import { useIntegrationAccess } from '@/hooks/use-integration-access' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' interface CredentialSelectorProps { blockId: string @@ -57,6 +60,7 @@ export function CredentialSelector({ const [editingValue, setEditingValue] = useState('') const [isEditing, setIsEditing] = useState(false) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const blockType = useWorkflowStore((state) => state.blocks[blockId]?.type) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) const label = subBlock.placeholder || 'Select credential' @@ -100,6 +104,20 @@ export function CredentialSelector({ const credentialKind = subBlock.credentialKind const isMergedKinds = credentialKind === 'any' + const { getAccess } = useIntegrationAccess(workspaceId, { + enabled: !isAllCredentials && credentialKind !== 'service-account', + }) + const { data: integrationAvailability, isSuccess: isAvailabilityReady } = + useIntegrationAvailability({ + enabled: !isAllCredentials && credentialKind !== 'service-account', + }) + const oauthConnectAvailable = + Boolean(blockType && getAccess(blockType) === 'available') && + isAvailabilityReady && + (integrationAvailability?.oauthServiceAvailability.some( + ({ providerId, available }) => providerId === effectiveProviderId && available + ) ?? + false) const credentials = useMemo(() => { // A service-account picker lists only the reusable service-account @@ -233,8 +251,9 @@ export function CredentialSelector({ setShowSetupModal(true) return } + if (!oauthConnectAvailable) return setShowConnectModal(true) - }, [credentialKind]) + }, [credentialKind, oauthConnectAvailable]) const getProviderIcon = useCallback((providerName: OAuthProvider) => { const { baseProvider } = parseProvider(providerName) @@ -278,7 +297,9 @@ export function CredentialSelector({ // Suppress the setup action when the service-account flow is preview-gated // for this viewer (a custom Slack bot needs slack_v2) — existing accounts // above stay selectable. - if (credentialKind !== 'service-account' || !serviceAccountConnectHidden) { + if ( + credentialKind === 'service-account' ? !serviceAccountConnectHidden : oauthConnectAvailable + ) { options.push({ label: credentialKind === 'service-account' @@ -302,6 +323,7 @@ export function CredentialSelector({ credentialKind, subBlock.credentialLabels, serviceAccountConnectHidden, + oauthConnectAvailable, serviceAccountTarget, provider, getProviderIcon, @@ -323,11 +345,15 @@ export function CredentialSelector({ section: labels?.oauthGroup ?? `${getProviderName(provider)} accounts`, items: [ ...credentials.filter((c) => c.type !== 'service_account').map(toOption), - { - label: labels?.oauthConnect ?? `Connect ${getProviderName(provider)} account`, - value: '__connect_account__', - iconElement: , - }, + ...(oauthConnectAvailable + ? [ + { + label: labels?.oauthConnect ?? `Connect ${getProviderName(provider)} account`, + value: '__connect_account__', + iconElement: , + }, + ] + : []), ], }, { @@ -355,6 +381,7 @@ export function CredentialSelector({ subBlock.credentialLabels, credentials, serviceAccountConnectHidden, + oauthConnectAvailable, serviceAccountTarget, provider, getProviderIcon, @@ -407,10 +434,6 @@ export function CredentialSelector({ const handleComboboxChange = useCallback( (value: string) => { if (value === '__connect_account__') { - if (isMergedKinds) { - setShowConnectModal(true) - return - } handleAddCredential() return } @@ -432,14 +455,7 @@ export function CredentialSelector({ setIsEditing(true) setEditingValue(value) }, - [ - isAllCredentials, - isMergedKinds, - allWorkspaceCredentials, - credentials, - handleAddCredential, - handleSelect, - ] + [isAllCredentials, allWorkspaceCredentials, credentials, handleAddCredential, handleSelect] ) return ( @@ -470,31 +486,32 @@ export function CredentialSelector({ {dataversePolicy.message} - {!dataversePolicy.hasInvalidEnvironment && ( - { - if (dataversePolicy.requiresSeparateCredential) { - setShowConnectModal(true) - return - } - writeOAuthReturnContext({ - origin: 'workflow', - workflowId: activeWorkflowId || '', - displayName: selectedCredential?.name ?? getProviderName(provider), - providerId: effectiveProviderId, - preCount: credentials.filter((c) => c.type !== 'service_account').length, - workspaceId, - reconnect: true, - requestedAt: Date.now(), - }) - setShowOAuthModal(true) - }} - > - {dataversePolicy.actionLabel} - - )} + {!dataversePolicy.hasInvalidEnvironment && + (!dataversePolicy.requiresSeparateCredential || oauthConnectAvailable) && ( + { + if (dataversePolicy.requiresSeparateCredential) { + setShowConnectModal(true) + return + } + writeOAuthReturnContext({ + origin: 'workflow', + workflowId: activeWorkflowId || '', + displayName: selectedCredential?.name ?? getProviderName(provider), + providerId: effectiveProviderId, + preCount: credentials.filter((c) => c.type !== 'service_account').length, + workspaceId, + reconnect: true, + requestedAt: Date.now(), + }) + setShowOAuthModal(true) + }} + > + {dataversePolicy.actionLabel} + + )} )} diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 39d2c1ce012..ba0ddfd509f 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -4,6 +4,7 @@ import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model- import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { + BEDROCK_SAVED_CREDENTIAL_INPUTS, getAgentModelOptions, getModelCapabilityCondition, getProviderCredentialSubBlocks, @@ -136,7 +137,7 @@ Return ONLY the JSON array.`, options: getAgentModelOptions, commandSearchable: true, }, - ...getProviderCredentialSubBlocks(), + ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }), { id: 'evaluationState', title: 'State', @@ -559,6 +560,7 @@ Return ONLY the JSON array.`, bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' }, bedrockSecretKey: { type: 'string', description: 'AWS Secret Access Key for Bedrock' }, bedrockRegion: { type: 'string', description: 'AWS region for Bedrock' }, + ...BEDROCK_SAVED_CREDENTIAL_INPUTS, responseFormat: { type: 'json', description: 'JSON response format schema', diff --git a/apps/sim/blocks/blocks/amplitude.ts b/apps/sim/blocks/blocks/amplitude.ts index f2ce1a2f318..0adc3b1ce57 100644 --- a/apps/sim/blocks/blocks/amplitude.ts +++ b/apps/sim/blocks/blocks/amplitude.ts @@ -1,5 +1,6 @@ import { AmplitudeIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' /* * Amplitude identifies a person by user id or device id — either one alone is @@ -12,6 +13,8 @@ const PROFILE_IDENTITY_FIELD = ['profileUserId', 'profileDeviceId'] as const export const AmplitudeBlock: BlockConfig = { type: 'amplitude', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'amplitude_v2' }, name: 'Amplitude', description: 'Track events and query analytics from Amplitude', longDescription: @@ -1231,6 +1234,29 @@ export const AmplitudeBlock: BlockConfig = { }, } +/** + * Every operation authenticates with the project credential: the ingestion operations send its + * API key, and the analytics and User Profile operations send both keys, so the picker shows for + * all of them. + */ +export const AmplitudeV2Block: BlockConfig = { + ...AmplitudeBlock, + type: 'amplitude_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential( + AmplitudeBlock, + { + id: 'credential', + type: 'oauth-input', + serviceId: 'amplitude', + title: 'Amplitude Credential', + }, + { condition: null, required: true } + ), +} + export const AmplitudeBlockMeta = { tags: ['data-analytics', 'marketing'], url: 'https://amplitude.com', diff --git a/apps/sim/blocks/blocks/apollo.ts b/apps/sim/blocks/blocks/apollo.ts index df3cc143894..aba5a2b5a8a 100644 --- a/apps/sim/blocks/blocks/apollo.ts +++ b/apps/sim/blocks/blocks/apollo.ts @@ -1600,5 +1600,6 @@ export const ApolloV2Block: BlockConfig = { type: 'oauth-input', serviceId: 'apollo', title: 'Apollo Credential', + credentialKind: 'any', }), } diff --git a/apps/sim/blocks/blocks/athena.ts b/apps/sim/blocks/blocks/athena.ts index 923de16a681..eb99711a3ca 100644 --- a/apps/sim/blocks/blocks/athena.ts +++ b/apps/sim/blocks/blocks/athena.ts @@ -341,6 +341,48 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks }, mode: 'advanced', }, + { + id: 'encryptionOption', + title: 'Result Encryption', + type: 'dropdown', + options: [ + { label: 'Workgroup default', id: '' }, + { label: 'SSE-S3', id: 'SSE_S3' }, + { label: 'SSE-KMS', id: 'SSE_KMS' }, + { label: 'CSE-KMS', id: 'CSE_KMS' }, + ], + value: () => '', + condition: { field: 'operation', value: 'start_query' }, + mode: 'advanced', + }, + { + id: 'kmsKey', + title: 'KMS Key', + type: 'short-input', + placeholder: 'arn:aws:kms:us-east-1:123456789012:key/...', + condition: { + field: 'operation', + value: 'start_query', + and: { field: 'encryptionOption', value: ['SSE_KMS', 'CSE_KMS'] }, + }, + mode: 'advanced', + }, + { + id: 'expectedBucketOwner', + title: 'Expected Bucket Owner', + type: 'short-input', + placeholder: '123456789012', + condition: { field: 'operation', value: 'start_query' }, + mode: 'advanced', + }, + { + id: 'clientRequestToken', + title: 'Client Request Token', + type: 'short-input', + placeholder: 'Unique 32-128 character idempotency token', + condition: { field: 'operation', value: 'start_query' }, + mode: 'advanced', + }, { id: 'queryExecutionId', title: 'Query Execution ID', @@ -670,6 +712,12 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks ...(resultReuseEnabled !== undefined && { resultReuseEnabled }), ...(resultReuseEnabled && resultReuseMaxAgeInMinutes !== undefined && { resultReuseMaxAgeInMinutes }), + ...(rest.encryptionOption && { encryptionOption: rest.encryptionOption }), + ...(rest.encryptionOption && + rest.encryptionOption !== 'SSE_S3' && + rest.kmsKey && { kmsKey: rest.kmsKey }), + ...(rest.expectedBucketOwner && { expectedBucketOwner: rest.expectedBucketOwner }), + ...(rest.clientRequestToken && { clientRequestToken: rest.clientRequestToken }), } case 'get_query_execution': @@ -1014,6 +1062,19 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks description: 'Comma-separated prepared statement names (up to 256)', }, tableName: { type: 'string', description: 'Table name to get metadata for' }, + encryptionOption: { + type: 'string', + description: 'Query result encryption (SSE_S3, SSE_KMS, CSE_KMS)', + }, + kmsKey: { type: 'string', description: 'KMS key for SSE_KMS or CSE_KMS result encryption' }, + expectedBucketOwner: { + type: 'string', + description: 'Account ID expected to own the output location bucket', + }, + clientRequestToken: { + type: 'string', + description: 'Idempotency token for starting a query (32-128 characters)', + }, }, outputs: { queryExecutionId: { @@ -1081,9 +1142,38 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks type: 'string', description: 'S3 location of query results', }, + servicePreProcessingTimeInMillis: { + type: 'number', + description: 'Time Athena spent preprocessing the query, in ms', + }, + serviceProcessingTimeInMillis: { + type: 'number', + description: 'Time Athena spent publishing results after execution, in ms', + }, + dataManifestLocation: { + type: 'string', + description: 'S3 location of the manifest of files written by the query', + }, + reusedPreviousResult: { + type: 'boolean', + description: 'Whether the query reused a previous result', + }, + athenaError: { + type: 'json', + description: + 'Failure details for a FAILED query (errorCategory, errorType, retryable, errorMessage)', + }, + executionParameters: { + type: 'array', + description: 'Values supplied for the query placeholders', + }, + substatementType: { + type: 'string', + description: 'Kind of statement that was run (e.g., SELECT, INSERT)', + }, columns: { type: 'array', - description: 'Column metadata (name and type)', + description: 'Column metadata (name, type, label, precision, scale, nullable, caseSensitive)', }, rows: { type: 'array', @@ -1212,7 +1302,7 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks }, engineVersion: { type: 'json', - description: 'Workgroup engine version (selected and effective)', + description: 'Engine version of the workgroup or query execution (selected and effective)', }, identityCenterApplicationArn: { type: 'string', diff --git a/apps/sim/blocks/blocks/aws-stale-inputs.test.ts b/apps/sim/blocks/blocks/aws-stale-inputs.test.ts new file mode 100644 index 00000000000..c4fac1d3568 --- /dev/null +++ b/apps/sim/blocks/blocks/aws-stale-inputs.test.ts @@ -0,0 +1,107 @@ +/** + * The executor hands a tool `{ ...rawInputs, ...blockParams }`, so a subBlock value the + * block mapper leaves unassigned reaches the tool raw. These cases keep stored values + * from another operation, and empty table rows, out of requests. + */ +import { describe, expect, it } from 'vitest' +import { awsCloudformationCreateChangeSetContract } from '@/lib/api/contracts/tools/aws/cloudformation-create-change-set' +import { awsDynamodbScanContract } from '@/lib/api/contracts/tools/aws/dynamodb-scan' +import { awsIamCreateUserContract } from '@/lib/api/contracts/tools/aws/iam-create-user' +import { awsIamListPoliciesContract } from '@/lib/api/contracts/tools/aws/iam-list-policies' +import { awsSesSendTemplatedEmailContract } from '@/lib/api/contracts/tools/aws/ses-send-templated-email' +import { CloudFormationBlock } from '@/blocks/blocks/cloudformation' +import { DynamoDBBlock } from '@/blocks/blocks/dynamodb' +import { IAMBlock } from '@/blocks/blocks/iam' +import { SESBlock } from '@/blocks/blocks/ses' +import type { BlockConfig } from '@/blocks/types' +import { createChangeSetTool } from '@/tools/cloudformation/create_change_set' +import { scanTool } from '@/tools/dynamodb/scan' +import { createUserTool } from '@/tools/iam/create_user' +import { listPoliciesTool } from '@/tools/iam/list_policies' +import { sendTemplatedEmailTool } from '@/tools/ses/send_templated_email' + +const AWS = { region: 'us-east-1', accessKeyId: 'AKIAEXAMPLE', secretAccessKey: 'secret' } + +function executorParams(block: BlockConfig, inputs: Record) { + const mapped = block.tools.config?.params?.(inputs) ?? {} + return { ...inputs, ...mapped } as Record +} + +describe('AWS blocks keep stale subBlock values out of tool input', () => { + it('scan ignores the get consistentRead and query indexName values', () => { + const params = executorParams(DynamoDBBlock, { + ...AWS, + operation: 'scan', + tableName: 'orders', + consistentRead: 'false', + indexName: 'by-customer', + }) + const body = scanTool.operation.input(params as never) as Record + expect(body.indexName).toBeUndefined() + expect(awsDynamodbScanContract.body?.safeParse(body).success).toBe(true) + }) + + it('templated send ignores the send_email Reply-To', () => { + const params = executorParams(SESBlock, { + ...AWS, + operation: 'send_templated_email', + fromAddress: 'from@example.com', + toAddresses: 'to@example.com', + templateName: 'welcome', + templateData: '{}', + replyToAddresses: 'stale@example.com', + }) + const body = sendTemplatedEmailTool.operation.input(params as never) as Record + expect(body.replyToAddresses).toBeUndefined() + expect(awsSesSendTemplatedEmailContract.body?.safeParse(body).success).toBe(true) + }) + + it('create change set ignores the stack tags', () => { + const params = executorParams(CloudFormationBlock, { + awsRegion: 'us-east-1', + awsAccessKeyId: 'AKIAEXAMPLE', + awsSecretAccessKey: 'secret', + operation: 'create_change_set', + stackName: 'app', + changeSetName: 'cs-1', + templateBody: '{}', + tags: '[{"key":"team","value":"old"}]', + }) + const body = createChangeSetTool.operation.input(params as never) as Record + expect(body.tags).toBeUndefined() + expect(awsCloudformationCreateChangeSetContract.body?.safeParse(body).success).toBe(true) + }) + + it('create user accepts an untouched Tags table', () => { + const params = executorParams(IAMBlock, { + ...AWS, + operation: 'create_user', + userName: 'alice', + tags: [{ id: 'row-1', cells: { key: '', value: '' } }], + }) + const body = createUserTool.operation.input(params as never) + expect(awsIamCreateUserContract.body?.safeParse(body).success).toBe(true) + }) + + it('list policies with the All usage filter sends no filter', () => { + const params = executorParams(IAMBlock, { + ...AWS, + operation: 'list_policies', + policyUsageFilter: 'all', + }) + const body = listPoliciesTool.operation.input(params as never) as Record + expect(body.policyUsageFilter).toBeUndefined() + expect(awsIamListPoliciesContract.body?.safeParse(body).success).toBe(true) + }) + + it('create user rejects a JSON-array tags string with no valid entries', () => { + expect(() => + executorParams(IAMBlock, { + ...AWS, + operation: 'create_user', + userName: 'alice', + tags: '[{"name":"team","val":"core"}]', + }) + ).toThrow('Tags must be a list of {"key", "value"} entries or a JSON object') + }) +}) diff --git a/apps/sim/blocks/blocks/azure_data_explorer.ts b/apps/sim/blocks/blocks/azure_data_explorer.ts index cf7afe9e2a4..8327179942c 100644 --- a/apps/sim/blocks/blocks/azure_data_explorer.ts +++ b/apps/sim/blocks/blocks/azure_data_explorer.ts @@ -1,6 +1,7 @@ import { AzureDataExplorerIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' /** Every operation except the cluster-level database listing runs in a database. */ const DATABASE_SCOPED_OPERATIONS = [ @@ -109,6 +110,8 @@ function toBoolean(value: unknown): true | undefined { export const AzureDataExplorerBlock: BlockConfig = { type: 'azure_data_explorer', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'azure_data_explorer_v2' }, name: 'Azure Data Explorer', description: 'Query and manage Azure Data Explorer (Kusto) clusters with KQL', longDescription: @@ -537,6 +540,11 @@ export const AzureDataExplorerBlock: BlockConfig = { rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, totalRowCount: { type: 'number', description: 'Rows Kusto returned, before the row cap' }, truncated: { type: 'boolean', description: 'Whether rows were dropped to stay within the cap' }, + additionalResults: { + type: 'json', + description: + 'Further result tables when a query batch or fork returns more than one (Run Query); each has tableName, columns, rows, records, rowCount, totalRowCount, truncated', + }, databases: { type: 'json', description: 'Database names (List Databases)' }, tables: { type: 'json', description: 'Table names (List Tables)' }, extentIds: { type: 'json', description: 'Extent IDs created (Ingest Rows Inline)' }, @@ -654,3 +662,24 @@ export const AzureDataExplorerBlockMeta = { }, ], } as const satisfies BlockMeta + +/** + * Runs as a saved Microsoft app credential (client secret, certificate, or keyless federated + * credential) instead of an inline client secret. The cluster stays a block field; the token is + * minted for it in the credential's own cloud. + */ +export const AzureDataExplorerV2Block: BlockConfig = { + ...AzureDataExplorerBlock, + type: 'azure_data_explorer_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + longDescription: + 'Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Runs as a Microsoft app credential: an app registration signing in with a client secret, a certificate, or a keyless federated credential, in the global cloud or a national cloud, so no interactive sign-in is needed.', + ...withSavedCredential(AzureDataExplorerBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'azure-data-explorer', + title: 'Microsoft App Credential', + }), +} diff --git a/apps/sim/blocks/blocks/azure_devops.ts b/apps/sim/blocks/blocks/azure_devops.ts index 00e429090a7..6ef93a310c0 100644 --- a/apps/sim/blocks/blocks/azure_devops.ts +++ b/apps/sim/blocks/blocks/azure_devops.ts @@ -13,6 +13,15 @@ function normalizeDate(input: unknown): string | undefined { return /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value } +/** + * Dedicated Max Results value for an operation, falling back to the shared List Builds `top` + * that these tools read before they had their own field, so saved workflows keep their page size. + */ +function resolveTop(dedicated: unknown, shared: unknown): number | undefined { + const value = dedicated || shared + return value ? Number(value) : undefined +} + export const AzureDevOpsBlock: BlockConfig = { type: 'azure_devops', hideFromToolbar: true, @@ -198,6 +207,145 @@ export const AzureDevOpsBlock: BlockConfig = { condition: { field: 'operation', value: 'azure_devops_list_builds' }, mode: 'advanced', }, + { + id: 'pipelinesTop', + title: 'Max Results', + type: 'short-input', + placeholder: 'Server default when empty', + condition: { field: 'operation', value: 'azure_devops_list_pipelines' }, + mode: 'advanced', + }, + { + id: 'queryTop', + title: 'Max Results', + type: 'short-input', + placeholder: 'Server default when empty', + condition: { field: 'operation', value: 'azure_devops_query_work_items' }, + mode: 'advanced', + }, + { + id: 'commentsTop', + title: 'Max Results', + type: 'short-input', + placeholder: 'Server default when empty', + condition: { field: 'operation', value: 'azure_devops_get_comments' }, + mode: 'advanced', + }, + { + id: 'definitionIds', + title: 'Pipeline Definition IDs', + type: 'short-input', + placeholder: 'Comma-separated, e.g. 12,34', + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'statusFilter', + title: 'Filter by Status', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'In Progress', id: 'inProgress' }, + { label: 'Completed', id: 'completed' }, + { label: 'Cancelling', id: 'cancelling' }, + { label: 'Postponed', id: 'postponed' }, + { label: 'Not Started', id: 'notStarted' }, + { label: 'No Status', id: 'none' }, + ], + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'branchName', + title: 'Branch', + type: 'short-input', + placeholder: 'e.g. refs/heads/main', + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'reasonFilter', + title: 'Build Reason', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Manual', id: 'manual' }, + { label: 'CI', id: 'individualCI' }, + { label: 'Batched CI', id: 'batchedCI' }, + { label: 'Schedule', id: 'schedule' }, + { label: 'Pull Request', id: 'pullRequest' }, + { label: 'Build Completion', id: 'buildCompletion' }, + { label: 'Resource Trigger', id: 'resourceTrigger' }, + ], + value: () => '', + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'requestedFor', + title: 'Requested For', + type: 'short-input', + placeholder: 'User email or ID', + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'queryOrder', + title: 'Order', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Queued (newest first)', id: 'queueTimeDescending' }, + { label: 'Queued (oldest first)', id: 'queueTimeAscending' }, + { label: 'Started (newest first)', id: 'startTimeDescending' }, + { label: 'Started (oldest first)', id: 'startTimeAscending' }, + { label: 'Finished (newest first)', id: 'finishTimeDescending' }, + { label: 'Finished (oldest first)', id: 'finishTimeAscending' }, + ], + value: () => '', + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'minTime', + title: 'After', + type: 'short-input', + placeholder: 'ISO 8601, e.g. 2026-01-01T00:00:00Z', + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'maxTime', + title: 'Before', + type: 'short-input', + placeholder: 'ISO 8601, e.g. 2026-01-31T23:59:59Z', + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'continuationToken', + title: 'Continuation Token', + type: 'short-input', + placeholder: 'From a previous page', + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'pipelinesContinuationToken', + title: 'Continuation Token', + type: 'short-input', + placeholder: 'From a previous page', + condition: { field: 'operation', value: 'azure_devops_list_pipelines' }, + mode: 'advanced', + }, + { + id: 'commentsContinuationToken', + title: 'Continuation Token', + type: 'short-input', + placeholder: 'From a previous page', + condition: { field: 'operation', value: 'azure_devops_get_comments' }, + mode: 'advanced', + }, { id: 'buildId', title: 'Build ID', @@ -219,6 +367,22 @@ export const AzureDevOpsBlock: BlockConfig = { required: true, condition: { field: 'operation', value: 'azure_devops_get_build_log' }, }, + { + id: 'startLine', + title: 'Start Line', + type: 'short-input', + placeholder: 'First line to return', + condition: { field: 'operation', value: 'azure_devops_get_build_log' }, + mode: 'advanced', + }, + { + id: 'endLine', + title: 'End Line', + type: 'short-input', + placeholder: 'Last line to return', + condition: { field: 'operation', value: 'azure_devops_get_build_log' }, + mode: 'advanced', + }, { id: 'fromBuildId', title: 'From Build ID', @@ -480,6 +644,14 @@ export const AzureDevOpsBlock: BlockConfig = { condition: { field: 'operation', value: 'azure_devops_create_work_item' }, mode: 'advanced', }, + { + id: 'updateIterationPath', + title: 'Iteration Path', + type: 'short-input', + placeholder: 'e.g. MyProject\\Sprint 2', + condition: { field: 'operation', value: 'azure_devops_update_work_item' }, + mode: 'advanced', + }, { id: 'tags', title: 'Tags', @@ -508,6 +680,18 @@ export const AzureDevOpsBlock: BlockConfig = { required: true, condition: { field: 'operation', value: 'azure_devops_add_comment' }, }, + { + id: 'commentFormat', + title: 'Comment Format', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Markdown', id: 'markdown' }, + { label: 'HTML', id: 'html' }, + ], + condition: { field: 'operation', value: 'azure_devops_add_comment' }, + mode: 'advanced', + }, ...getTrigger('azure_devops_build_failed').subBlocks, ...getTrigger('azure_devops_work_item_created').subBlocks, ...getTrigger('azure_devops_webhook').subBlocks, @@ -542,7 +726,11 @@ export const AzureDevOpsBlock: BlockConfig = { } switch (params.operation) { case 'azure_devops_list_pipelines': - return base + return { + ...base, + top: resolveTop(params.pipelinesTop, params.top), + continuationToken: (params.pipelinesContinuationToken as string) || undefined, + } case 'azure_devops_get_pipeline': return { ...base, pipelineId: Number(params.pipelineId) } case 'azure_devops_list_pipeline_runs': @@ -554,11 +742,26 @@ export const AzureDevOpsBlock: BlockConfig = { ...base, resultFilter: (params.resultFilter as string) || undefined, top: params.top ? Number(params.top) : undefined, + definitionIds: (params.definitionIds as string) || undefined, + statusFilter: (params.statusFilter as string) || undefined, + branchName: (params.branchName as string) || undefined, + reasonFilter: (params.reasonFilter as string) || undefined, + requestedFor: (params.requestedFor as string) || undefined, + queryOrder: (params.queryOrder as string) || undefined, + minTime: normalizeDate(params.minTime), + maxTime: normalizeDate(params.maxTime), + continuationToken: (params.continuationToken as string) || undefined, } case 'azure_devops_list_build_logs': return { ...base, buildId: Number(params.buildId) } case 'azure_devops_get_build_log': - return { ...base, buildId: Number(params.buildId), logId: Number(params.logId) } + return { + ...base, + buildId: Number(params.buildId), + logId: Number(params.logId), + startLine: params.startLine ? Number(params.startLine) : undefined, + endLine: params.endLine ? Number(params.endLine) : undefined, + } case 'azure_devops_get_build_timeline': return { ...base, buildId: Number(params.buildId) } case 'azure_devops_get_work_items_between_builds': @@ -568,7 +771,11 @@ export const AzureDevOpsBlock: BlockConfig = { toBuildId: Number(params.toBuildId), } case 'azure_devops_query_work_items': - return { ...base, wiqlQuery: params.wiqlQuery as string } + return { + ...base, + wiqlQuery: params.wiqlQuery as string, + top: params.queryTop ? Number(params.queryTop) : undefined, + } case 'azure_devops_get_work_item': return { ...base, workItemId: Number(params.workItemId) } case 'azure_devops_get_work_items_batch': @@ -607,6 +814,7 @@ export const AzureDevOpsBlock: BlockConfig = { completedWork: params.completedWork ? Number(params.completedWork) : undefined, description: (params.description as string) || undefined, areaPath: (params.areaPath as string) || undefined, + iterationPath: (params.updateIterationPath as string) || undefined, tags: (params.tags as string) || undefined, } case 'azure_devops_add_comment': @@ -614,9 +822,15 @@ export const AzureDevOpsBlock: BlockConfig = { ...base, workItemId: Number(params.workItemId), text: params.commentText as string, + format: (params.commentFormat as string) || undefined, } case 'azure_devops_get_comments': - return { ...base, workItemId: Number(params.workItemId) } + return { + ...base, + workItemId: Number(params.workItemId), + top: resolveTop(params.commentsTop, params.top), + continuationToken: (params.commentsContinuationToken as string) || undefined, + } default: return base } @@ -633,6 +847,9 @@ export const AzureDevOpsBlock: BlockConfig = { runId: { type: 'number', description: 'Pipeline run ID' }, resultFilter: { type: 'string', description: 'Build result filter' }, top: { type: 'number', description: 'Maximum number of results' }, + pipelinesTop: { type: 'number', description: 'Maximum pipelines to return (list pipelines)' }, + queryTop: { type: 'number', description: 'Maximum work items to return (query work items)' }, + commentsTop: { type: 'number', description: 'Maximum comments to return (get comments)' }, buildId: { type: 'number', description: 'Build ID' }, logId: { type: 'number', description: 'Build log ID' }, fromBuildId: { type: 'number', description: 'Starting build ID for work item range' }, @@ -673,12 +890,33 @@ export const AzureDevOpsBlock: BlockConfig = { }, areaPath: { type: 'string', description: 'Area path' }, iterationPath: { type: 'string', description: 'Iteration path' }, + updateIterationPath: { type: 'string', description: 'New iteration path (update work item)' }, tags: { type: 'string', description: 'Semicolon-separated tags' }, state: { type: 'string', description: 'Basic-process work item state (To Do, Doing, Done)', }, commentText: { type: 'string', description: 'Comment text' }, + commentFormat: { type: 'string', description: 'Comment format (markdown or html)' }, + definitionIds: { type: 'string', description: 'Comma-separated pipeline definition IDs' }, + statusFilter: { type: 'string', description: 'Build status filter' }, + branchName: { type: 'string', description: 'Source branch filter (e.g. refs/heads/main)' }, + reasonFilter: { type: 'string', description: 'Build reason filter (list builds)' }, + requestedFor: { type: 'string', description: 'Requested-for user filter (list builds)' }, + queryOrder: { type: 'string', description: 'Build sort order (list builds)' }, + minTime: { type: 'string', description: 'Only builds after this ISO 8601 time' }, + maxTime: { type: 'string', description: 'Only builds before this ISO 8601 time' }, + continuationToken: { type: 'string', description: 'Continuation token for the next page' }, + pipelinesContinuationToken: { + type: 'string', + description: 'Continuation token for the next page of pipelines', + }, + commentsContinuationToken: { + type: 'string', + description: 'Continuation token for the next page of comments', + }, + startLine: { type: 'number', description: 'First log line to return' }, + endLine: { type: 'number', description: 'Last log line to return' }, }, outputs: { diff --git a/apps/sim/blocks/blocks/bitbucket.ts b/apps/sim/blocks/blocks/bitbucket.ts index efbdabc1d43..75f6a873b90 100644 --- a/apps/sim/blocks/blocks/bitbucket.ts +++ b/apps/sim/blocks/blocks/bitbucket.ts @@ -549,6 +549,30 @@ export const BitbucketBlock: BlockConfig = { }, placeholder: 'src/index.ts', }, + { + id: 'commitPath', + title: 'Changed Path', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: 'bitbucket_list_commits' }, + placeholder: 'Only commits that changed this file or directory (for example: src/)', + }, + { + id: 'commitInclude', + title: 'Include Refs', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: 'bitbucket_list_commits' }, + placeholder: 'Comma-separated branches, tags, or hashes (for example: main)', + }, + { + id: 'commitExclude', + title: 'Exclude Refs', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: 'bitbucket_list_commits' }, + placeholder: 'Comma-separated branches, tags, or hashes to leave out', + }, { id: 'state', title: 'Pull Request State', @@ -576,6 +600,7 @@ export const BitbucketBlock: BlockConfig = { type: 'short-input', required: true, condition: { field: 'operation', value: 'bitbucket_create_pull_request' }, + placeholder: 'Add retry handling to the sync job', }, { id: 'sourceBranch', @@ -583,6 +608,7 @@ export const BitbucketBlock: BlockConfig = { type: 'short-input', required: true, condition: { field: 'operation', value: 'bitbucket_create_pull_request' }, + placeholder: 'feature/my-branch', }, { id: 'destinationBranch', @@ -598,6 +624,7 @@ export const BitbucketBlock: BlockConfig = { type: 'long-input', mode: 'advanced', condition: { field: 'operation', value: 'bitbucket_create_pull_request' }, + placeholder: 'Describe the change (Markdown)', }, { id: 'reviewerAccountIds', @@ -663,6 +690,7 @@ export const BitbucketBlock: BlockConfig = { type: 'long-input', mode: 'advanced', condition: { field: 'operation', value: 'bitbucket_merge_pull_request' }, + placeholder: 'Defaults to the Bitbucket-generated merge message', }, { id: 'taskId', @@ -673,6 +701,7 @@ export const BitbucketBlock: BlockConfig = { field: 'operation', value: 'bitbucket_get_pull_request_merge_task_status', }, + placeholder: 'Task ID returned by Merge Pull Request', }, { id: 'content', @@ -680,6 +709,7 @@ export const BitbucketBlock: BlockConfig = { type: 'long-input', required: true, condition: { field: 'operation', value: 'bitbucket_create_pull_request_comment' }, + placeholder: 'Comment text (Markdown)', }, { id: 'parentId', @@ -708,6 +738,7 @@ export const BitbucketBlock: BlockConfig = { type: 'short-input', mode: 'advanced', condition: { field: 'operation', value: 'bitbucket_list_pipelines' }, + placeholder: 'main', }, { id: 'pipelineCommitHash', @@ -738,6 +769,7 @@ export const BitbucketBlock: BlockConfig = { type: 'short-input', mode: 'advanced', condition: { field: 'operation', value: 'bitbucket_list_pipelines' }, + placeholder: 'deploy-to-production', }, { id: 'pipelineTriggerType', @@ -797,6 +829,18 @@ export const BitbucketBlock: BlockConfig = { condition: { field: 'operation', value: 'bitbucket_trigger_pipeline' }, placeholder: 'Optional full commit SHA on the selected branch', }, + { + id: 'targetRefType', + title: 'Ref Type', + type: 'dropdown', + mode: 'advanced', + condition: { field: 'operation', value: 'bitbucket_trigger_pipeline' }, + options: [ + { label: 'Branch', id: 'branch' }, + { label: 'Tag', id: 'tag' }, + ], + value: () => 'branch', + }, { id: 'stepUuid', title: 'Step UUID', @@ -947,7 +991,13 @@ export const BitbucketBlock: BlockConfig = { case 'bitbucket_delete_branch': return { ...common, name: optionalString(params.branchName, 'branchName') } case 'bitbucket_list_commits': - return { ...common, ...pagination } + return { + ...common, + include: stringList(params.commitInclude, 'commitInclude'), + exclude: stringList(params.commitExclude, 'commitExclude'), + path: optionalString(params.commitPath, 'commitPath'), + ...pagination, + } case 'bitbucket_get_commit': return { ...common, commit: optionalString(params.revision, 'revision') } case 'bitbucket_list_directory': @@ -1047,7 +1097,7 @@ export const BitbucketBlock: BlockConfig = { case 'bitbucket_trigger_pipeline': return { ...common, - refType: 'branch', + refType: optionalString(params.targetRefType, 'targetRefType') ?? 'branch', refName: optionalString(params.targetRef, 'targetRef'), commitHash: optionalString(params.targetCommitHash, 'targetCommitHash'), } @@ -1083,6 +1133,18 @@ export const BitbucketBlock: BlockConfig = { target: { type: 'string', description: 'Full commit hash or existing ref for the new branch' }, revision: { type: 'string', description: 'Full commit SHA' }, path: { type: 'string', description: 'Repository-relative file or directory path' }, + commitPath: { + type: 'string', + description: 'Only list commits that changed this file or directory', + }, + commitInclude: { + type: 'string', + description: 'Comma-separated refs whose commits to include when listing commits', + }, + commitExclude: { + type: 'string', + description: 'Comma-separated refs whose commits to exclude when listing commits', + }, state: { type: 'string', description: 'Pull request state filter' }, prId: { type: 'number', description: 'Pull request ID' }, title: { type: 'string', description: 'Pull request title' }, @@ -1117,6 +1179,10 @@ export const BitbucketBlock: BlockConfig = { pipelineUuid: { type: 'string', description: 'Pipeline UUID' }, targetRef: { type: 'string', description: 'Pipeline branch target' }, targetCommitHash: { type: 'string', description: 'Optional pipeline target commit SHA' }, + targetRefType: { + type: 'string', + description: 'Pipeline target reference type (branch or tag)', + }, stepUuid: { type: 'string', description: 'Pipeline step UUID' }, maxCharacters: { type: 'number', description: 'Maximum bounded raw content length' }, }, diff --git a/apps/sim/blocks/blocks/cbinsights.ts b/apps/sim/blocks/blocks/cbinsights.ts index cde1b28a07a..d7d73cbaaf1 100644 --- a/apps/sim/blocks/blocks/cbinsights.ts +++ b/apps/sim/blocks/blocks/cbinsights.ts @@ -2,6 +2,7 @@ import { Building, ListChecks, Search, Sparkles, Sprout, Users } from '@sim/emcn import { CbInsightsIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' /** Operations that take a single organization ID on the path. */ const ORG_OPERATIONS = [ @@ -63,6 +64,8 @@ const DATE_RANGE_OPERATIONS = ['get_commercial_maturity_history', 'get_exit_prob export const CbInsightsBlock: BlockConfig = { type: 'cbinsights', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'cbinsights_v2' }, name: 'CB Insights', description: 'Research private markets — firmographics, funding, and predictive scores', longDescription: @@ -909,6 +912,21 @@ export const CbInsightsBlock: BlockConfig = { }, } +/** Every operation runs with a saved CB Insights API client. */ +export const CbInsightsV2Block: BlockConfig = { + ...CbInsightsBlock, + type: 'cbinsights_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(CbInsightsBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'cbinsights', + title: 'CB Insights Credential', + }), +} + export const CbInsightsBlockMeta = { tags: ['enrichment', 'data-analytics'], url: 'https://www.cbinsights.com', diff --git a/apps/sim/blocks/blocks/cloudformation.ts b/apps/sim/blocks/blocks/cloudformation.ts index 75c6b01007c..75eb9753de0 100644 --- a/apps/sim/blocks/blocks/cloudformation.ts +++ b/apps/sim/blocks/blocks/cloudformation.ts @@ -275,6 +275,48 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, placeholder: 'Describe the tags to apply...', }, }, + { + id: 'changeSetTags', + title: 'Tags (JSON)', + type: 'code', + placeholder: '[\n {"key": "env", "value": "prod"}\n]', + condition: { field: 'operation', value: 'create_change_set' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a CloudFormation stack tags JSON array based on the user's description. +Each item must be an object with "key" and "value" string fields. + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the tags to apply...', + }, + }, + { + id: 'roleArn', + title: 'Service Role ARN', + type: 'short-input', + placeholder: 'arn:aws:iam::123456789012:role/cfn-deploy (optional)', + condition: { + field: 'operation', + value: ['create_stack', 'update_stack', 'create_change_set'], + }, + mode: 'advanced', + }, + { + id: 'enableTerminationProtection', + title: 'Termination Protection', + type: 'switch', + condition: { field: 'operation', value: 'create_stack' }, + mode: 'advanced', + }, + { + id: 'changeSetNextToken', + title: 'Next Token', + type: 'short-input', + placeholder: 'nextToken from a previous Describe Change Set call', + condition: { field: 'operation', value: 'describe_change_set' }, + mode: 'advanced', + }, { id: 'onFailure', title: 'On Failure', @@ -437,6 +479,11 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, ...(parsedTimeoutInMinutes !== undefined && { timeoutInMinutes: parsedTimeoutInMinutes, }), + ...(rest.roleArn && { roleArn: rest.roleArn }), + ...((rest.enableTerminationProtection === true || + rest.enableTerminationProtection === 'true') && { + enableTerminationProtection: true, + }), } } @@ -457,6 +504,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, ...(parameters && { parameters: parseJson(parameters, 'parameters') }), ...(rest.capabilities && { capabilities: rest.capabilities }), ...(tags && { tags: parseJson(tags, 'tags') }), + ...(rest.roleArn && { roleArn: rest.roleArn }), } } @@ -501,6 +549,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, ...(rest.capabilities && { capabilities: rest.capabilities }), ...(rest.changeSetType && { changeSetType: rest.changeSetType }), ...(rest.changeSetDescription && { description: rest.changeSetDescription }), + tags: rest.changeSetTags ? parseJson(rest.changeSetTags, 'tags') : undefined, + ...(rest.roleArn && { roleArn: rest.roleArn }), } } @@ -512,6 +562,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, awsSecretAccessKey, changeSetName: rest.changeSetName, ...(rest.stackName && { stackName: rest.stackName }), + ...(rest.changeSetNextToken && { nextToken: rest.changeSetNextToken }), } } @@ -637,6 +688,16 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, parameters: { type: 'json', description: 'Stack input parameters' }, capabilities: { type: 'string', description: 'Comma-separated capabilities to acknowledge' }, tags: { type: 'json', description: 'Tags to apply to the stack' }, + changeSetTags: { type: 'json', description: 'Tags to apply through a change set' }, + roleArn: { type: 'string', description: 'IAM service role ARN CloudFormation assumes' }, + enableTerminationProtection: { + type: 'boolean', + description: 'Enable termination protection on a new stack', + }, + changeSetNextToken: { + type: 'string', + description: 'Pagination token for Describe Change Set changes', + }, onFailure: { type: 'string', description: 'Action to take on stack creation failure' }, timeoutInMinutes: { type: 'number', description: 'Stack creation timeout in minutes' }, retainResources: { @@ -652,7 +713,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, outputs: { stacks: { type: 'array', - description: 'List of CloudFormation stacks with status, outputs, and tags', + description: + 'CloudFormation stacks [{stackName, stackId, stackStatus, stackStatusReason, detailedStatus, creationTime, lastUpdatedTime, deletionTime, description, outputs [{outputKey, outputValue, description, exportName}], parameters [{parameterKey, parameterValue, resolvedValue}], tags, capabilities, driftInformation, roleArn, parentId, rootId, changeSetId, notificationArns, timeoutInMinutes, disableRollback, enableTerminationProtection}]', }, resources: { type: 'array', @@ -660,7 +722,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, }, events: { type: 'array', - description: 'Stack events with resource status and timestamps', + description: + 'Stack events [{eventId, logicalResourceId, physicalResourceId, resourceType, resourceStatus, resourceStatusReason, detailedStatus, resourceProperties, clientRequestToken, operationId, hook fields, timestamp}]', }, stackDriftDetectionId: { type: 'string', @@ -704,7 +767,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, }, parameters: { type: 'array', - description: 'Template parameters', + description: + 'Template parameters (validate/template summary) or change set parameters (parameterKey, parameterValue, resolvedValue)', }, capabilities: { type: 'array', @@ -756,7 +820,40 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, }, changes: { type: 'array', - description: 'List of resource changes a change set would make', + description: + 'Resource changes a change set would make [{action, logicalResourceId, physicalResourceId, resourceType, replacement, scope, details}]', + }, + tags: { + type: 'array', + description: 'Tags associated with the change set', + }, + notificationArns: { + type: 'array', + description: 'SNS topic ARNs for change set stack events', + }, + includeNestedStacks: { + type: 'boolean', + description: 'Whether change sets were created for nested stacks', + }, + parentChangeSetId: { + type: 'string', + description: 'Parent change set ID, for nested change sets', + }, + rootChangeSetId: { + type: 'string', + description: 'Top-level change set ID, for nested change sets', + }, + onStackFailure: { + type: 'string', + description: 'Behavior when change set stack operations fail', + }, + importExistingResources: { + type: 'boolean', + description: 'Whether the change set imports existing resources', + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of change set changes', }, }, } diff --git a/apps/sim/blocks/blocks/cloudwatch.ts b/apps/sim/blocks/blocks/cloudwatch.ts index ccaf98917c8..83445492d2e 100644 --- a/apps/sim/blocks/blocks/cloudwatch.ts +++ b/apps/sim/blocks/blocks/cloudwatch.ts @@ -497,6 +497,22 @@ Return ONLY the filter pattern - no explanations, no extra text.`, columns: ['name', 'value'], condition: { field: 'operation', value: 'get_metric_statistics' }, }, + { + id: 'metricExtendedStatistics', + title: 'Percentile Statistics', + type: 'short-input', + placeholder: 'p99, p95 (requested instead of Statistics when set)', + condition: { field: 'operation', value: 'get_metric_statistics' }, + mode: 'advanced', + }, + { + id: 'metricStatisticsUnit', + title: 'Unit Filter', + type: 'short-input', + placeholder: 'e.g., Percent, Milliseconds (omit for all units)', + condition: { field: 'operation', value: 'get_metric_statistics' }, + mode: 'advanced', + }, { id: 'alarmNamePrefix', title: 'Alarm Name Prefix', @@ -651,6 +667,30 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, mode: 'advanced', description: 'Unix epoch seconds. Defaults to now (mute starts immediately).', }, + { + id: 'filterNextToken', + title: 'Next Token', + type: 'short-input', + placeholder: 'nextToken from a previous Filter Log Events call', + condition: { field: 'operation', value: 'filter_log_events' }, + mode: 'advanced', + }, + { + id: 'logEventsNextToken', + title: 'Next Token', + type: 'short-input', + placeholder: 'nextForwardToken or nextBackwardToken from a previous call', + condition: { field: 'operation', value: 'get_log_events' }, + mode: 'advanced', + }, + { + id: 'alarmsNextToken', + title: 'Next Token', + type: 'short-input', + placeholder: 'nextToken from a previous Describe Alarms call', + condition: { field: 'operation', value: 'describe_alarms' }, + mode: 'advanced', + }, { id: 'limit', title: 'Limit', @@ -788,6 +828,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, ...(startTime && { startTime: Number(startTime) }), ...(endTime && { endTime: Number(endTime) }), ...(parsedLimit !== undefined && { limit: parsedLimit }), + ...(rest.logEventsNextToken && { nextToken: rest.logEventsNextToken }), } } @@ -826,6 +867,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, startFromHead: rest.startFromHead === 'true' || rest.startFromHead === true, }), ...(parsedLimit !== undefined && { limit: parsedLimit }), + ...(rest.filterNextToken && { nextToken: rest.filterNextToken }), } } @@ -892,6 +934,15 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, endTime: Number(endTime), period: Number(rest.metricPeriod), statistics: Array.isArray(stat) ? stat : [stat], + ...(rest.metricExtendedStatistics && { + extendedStatistics: Array.isArray(rest.metricExtendedStatistics) + ? rest.metricExtendedStatistics + : String(rest.metricExtendedStatistics) + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean), + }), + ...(rest.metricStatisticsUnit && { unit: rest.metricStatisticsUnit }), ...(rest.metricDimensions && { dimensions: (() => { const dims = rest.metricDimensions @@ -962,6 +1013,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, ...(rest.stateValue && { stateValue: rest.stateValue }), ...(rest.alarmType && { alarmType: rest.alarmType }), ...(parsedLimit !== undefined && { limit: parsedLimit }), + ...(rest.alarmsNextToken && { nextToken: rest.alarmsNextToken }), } case 'describe_alarm_history': @@ -1099,6 +1151,20 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, metricPeriod: { type: 'number', description: 'Granularity in seconds' }, metricStatistics: { type: 'string', description: 'Statistic type (Average, Sum, etc.)' }, metricDimensions: { type: 'json', description: 'Metric dimensions (Name/Value pairs)' }, + metricExtendedStatistics: { + type: 'string', + description: 'Comma-separated percentile statistics (e.g., p99, p95)', + }, + metricStatisticsUnit: { + type: 'string', + description: 'Only return metric data reported with this unit', + }, + filterNextToken: { type: 'string', description: 'Pagination token for Filter Log Events' }, + logEventsNextToken: { + type: 'string', + description: 'Forward or backward pagination token for Get Log Events', + }, + alarmsNextToken: { type: 'string', description: 'Pagination token for Describe Alarms' }, metricValue: { type: 'number', description: 'Metric value to publish' }, metricUnit: { type: 'string', description: 'Metric unit (Count, Seconds, Bytes, etc.)' }, publishDimensions: { @@ -1138,13 +1204,30 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, type: 'string', description: 'Query completion status', }, + queryId: { + type: 'string', + description: 'ID of the Logs Insights query', + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page (Filter Log Events, Describe Alarms)', + }, + nextForwardToken: { + type: 'string', + description: 'Token for the next, newer page of Get Log Events results', + }, + nextBackwardToken: { + type: 'string', + description: 'Token for the previous, older page of Get Log Events results', + }, logGroups: { type: 'array', description: 'List of CloudWatch log groups', }, events: { type: 'array', - description: 'Log events with timestamp and message', + description: + 'Log events [{timestamp, message, ingestionTime, logStreamName, eventId}] (logStreamName and eventId from Filter Log Events)', }, logStreams: { type: 'array', @@ -1160,15 +1243,18 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, }, datapoints: { type: 'array', - description: 'Metric datapoints with timestamps and values', + description: + 'Metric datapoints [{timestamp, average, sum, minimum, maximum, sampleCount, unit, extendedStatistics}]', }, alarms: { type: 'array', - description: 'CloudWatch alarms with state and configuration', + description: + 'CloudWatch alarms [{alarmName, alarmArn, alarmType, stateValue, stateReason, stateReasonData, alarmDescription, actionsEnabled, alarmActions, okActions, insufficientDataActions, metricName, namespace, statistic, extendedStatistic, dimensions, period, unit, threshold, comparisonOperator, evaluationPeriods, datapointsToAlarm, treatMissingData, metrics, alarmRule, stateUpdatedTimestamp, stateTransitionedTimestamp}]', }, alarmHistoryItems: { type: 'array', - description: 'Alarm history items (state changes, configuration updates, actions)', + description: + 'Alarm history items [{alarmName, alarmType, timestamp, historyItemType, historySummary, historyData, alarmContributorId}]', }, alarmNames: { type: 'array', diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts index 3f627ea18a8..4e1d31a0c05 100644 --- a/apps/sim/blocks/blocks/confluence.ts +++ b/apps/sim/blocks/blocks/confluence.ts @@ -1041,6 +1041,20 @@ export const ConfluenceV2Block: BlockConfig = { required: true, condition: { field: 'operation', value: ['search', 'search_in_space'] }, }, + { + id: 'cql', + title: 'CQL Filter', + type: 'long-input', + placeholder: 'type = page AND space = "ENG" AND lastModified > now("-7d")', + mode: 'advanced', + condition: { field: 'operation', value: 'search' }, + wandConfig: { + enabled: true, + prompt: + 'Write Confluence CQL clauses that are ANDed with a text search. Use fields such as type (page, blogpost, attachment, comment), space, label, creator, contributor, created, lastModified, ancestor, and title, with now("-7d")-style relative dates. Do not include a text ~ clause. Return ONLY the CQL expression.', + placeholder: 'Describe how to narrow the search', + }, + }, { id: 'comment', title: 'Comment Text', @@ -1057,6 +1071,14 @@ export const ConfluenceV2Block: BlockConfig = { required: true, condition: { field: 'operation', value: ['update_comment', 'delete_comment'] }, }, + { + id: 'parentCommentId', + title: 'Reply To Comment ID', + type: 'short-input', + placeholder: 'Enter a footer or inline comment ID to reply to (optional)', + mode: 'advanced', + condition: { field: 'operation', value: 'create_comment' }, + }, { id: 'attachmentId', title: 'Attachment ID', @@ -1158,6 +1180,33 @@ export const ConfluenceV2Block: BlockConfig = { value: () => 'storage', condition: { field: 'operation', value: 'list_comments' }, }, + { + id: 'commentType', + title: 'Comment Type', + type: 'dropdown', + options: [ + { label: 'Footer (default)', id: 'footer' }, + { label: 'Inline', id: 'inline' }, + ], + value: () => 'footer', + condition: { field: 'operation', value: 'list_comments' }, + }, + { + id: 'contentBodyFormat', + title: 'Body Format', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Storage', id: 'storage' }, + { label: 'Atlas Doc Format', id: 'atlas_doc_format' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_blogpost', 'list_pages_in_space', 'list_blogposts_in_space'], + }, + }, { id: 'limit', title: 'Limit', @@ -1214,6 +1263,14 @@ export const ConfluenceV2Block: BlockConfig = { ], }, }, + { + id: 'searchCursor', + title: 'Pagination Cursor', + type: 'short-input', + placeholder: 'Enter nextCursor from a previous search (optional)', + mode: 'advanced', + condition: { field: 'operation', value: ['search', 'search_in_space'] }, + }, ...getTrigger('confluence_page_created').subBlocks, ...getTrigger('confluence_page_updated').subBlocks, @@ -1445,10 +1502,20 @@ export const ConfluenceV2Block: BlockConfig = { spacePropertyKey, spacePropertyValue, spacePropertyId, + commentType, + contentBodyFormat, + parentCommentId, + cql, + searchCursor, ...rest } = params const effectivePageId = pageId ? String(pageId).trim() : '' + const scopedBodyFormat = + ['get_blogpost', 'list_pages_in_space', 'list_blogposts_in_space'].includes(operation) && + contentBodyFormat + ? { bodyFormat: contentBodyFormat } + : {} if (operation === 'add_label') { return { @@ -1486,6 +1553,27 @@ export const ConfluenceV2Block: BlockConfig = { operation, bodyFormat: bodyFormat || 'storage', cursor: cursor || undefined, + commentType: commentType || undefined, + ...rest, + } + } + + if (operation === 'create_comment') { + return { + credential: oauthCredential, + pageId: effectivePageId, + operation, + parentCommentId: parentCommentId ? String(parentCommentId).trim() : undefined, + ...rest, + } + } + + if (operation === 'search') { + return { + credential: oauthCredential, + operation, + cql: cql || undefined, + cursor: searchCursor || undefined, ...rest, } } @@ -1514,6 +1602,7 @@ export const ConfluenceV2Block: BlockConfig = { pageId: effectivePageId || undefined, operation, cursor, + ...scopedBodyFormat, ...rest, } } @@ -1611,6 +1700,7 @@ export const ConfluenceV2Block: BlockConfig = { credential: oauthCredential, operation, spaceKey: selectedSpaceKey, + cursor: searchCursor || undefined, ...rest, } } @@ -1708,6 +1798,7 @@ export const ConfluenceV2Block: BlockConfig = { blogPostId: blogPostId || undefined, versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined, operation, + ...scopedBodyFormat, ...rest, } }, @@ -1742,6 +1833,14 @@ export const ConfluenceV2Block: BlockConfig = { blogPostStatus: { type: 'string', description: 'Blog post status (current or draft)' }, purge: { type: 'boolean', description: 'Permanently delete instead of moving to trash' }, bodyFormat: { type: 'string', description: 'Body format for comments' }, + commentType: { type: 'string', description: 'Comment type to list (footer or inline)' }, + contentBodyFormat: { + type: 'string', + description: 'Body format for blog post and page list/get operations', + }, + parentCommentId: { type: 'string', description: 'Comment ID to reply to' }, + cql: { type: 'string', description: 'Extra CQL clauses ANDed with the search query' }, + searchCursor: { type: 'string', description: 'Pagination cursor for search operations' }, limit: { type: 'number', description: 'Maximum number of results' }, cursor: { type: 'string', description: 'Pagination cursor from previous response' }, taskId: { type: 'string', description: 'Task identifier' }, @@ -1808,6 +1907,23 @@ export const ConfluenceV2Block: BlockConfig = { homepageId: { type: 'string', description: 'Space homepage ID' }, description: { type: 'json', description: 'Space description' }, nextCursor: { type: 'string', description: 'Cursor for fetching next page of results' }, + totalSize: { type: 'number', description: 'Total number of matching search results' }, + ownerId: { type: 'string', description: 'Account ID of the current page owner (Read Page)' }, + lastOwnerId: { + type: 'string', + description: 'Account ID of the previous page owner (Read Page)', + }, + parentType: { type: 'string', description: 'Parent content type (Read Page)' }, + parentId: { type: 'string', description: 'Parent page identifier' }, + parentCommentId: { + type: 'string', + description: 'Parent comment ID when the comment is a reply', + }, + spaceOwnerId: { type: 'string', description: 'Account ID of the space owner (Get Space)' }, + webUrl: { type: 'string', description: 'URL to view the blog post or comment in Confluence' }, + authorId: { type: 'string', description: 'Account ID of the content author' }, + createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, + spaceKey: { type: 'string', description: 'Space key that was searched (Search in Space)' }, }, } diff --git a/apps/sim/blocks/blocks/dynamodb.ts b/apps/sim/blocks/blocks/dynamodb.ts index dacec45fb7a..1a67c12ad29 100644 --- a/apps/sim/blocks/blocks/dynamodb.ts +++ b/apps/sim/blocks/blocks/dynamodb.ts @@ -588,6 +588,117 @@ Return ONLY the expression - no explanations.`, condition: { field: 'operation', value: 'query' }, mode: 'advanced', }, + { + id: 'getProjectionExpression', + title: 'Projection Expression', + type: 'short-input', + placeholder: 'pk, #name, email', + condition: { field: 'operation', value: 'get' }, + mode: 'advanced', + }, + { + id: 'getExpressionAttributeNames', + title: 'Expression Attribute Names (JSON)', + type: 'code', + placeholder: '{\n "#name": "name"\n}', + condition: { field: 'operation', value: 'get' }, + mode: 'advanced', + }, + { + id: 'queryProjectionExpression', + title: 'Projection Expression', + type: 'short-input', + placeholder: 'pk, sk, #name', + condition: { field: 'operation', value: 'query' }, + mode: 'advanced', + }, + { + id: 'queryConsistentRead', + title: 'Consistent Read', + type: 'dropdown', + options: [ + { label: 'Eventually Consistent', id: 'false' }, + { label: 'Strongly Consistent', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'query' }, + mode: 'advanced', + }, + { + id: 'scanIndexName', + title: 'Index Name', + type: 'short-input', + placeholder: 'GSI1', + condition: { field: 'operation', value: 'scan' }, + mode: 'advanced', + }, + { + id: 'scanConsistentRead', + title: 'Consistent Read', + type: 'dropdown', + options: [ + { label: 'Eventually Consistent', id: 'false' }, + { label: 'Strongly Consistent', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'scan' }, + mode: 'advanced', + }, + { + id: 'scanSegment', + title: 'Segment', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'scan' }, + mode: 'advanced', + }, + { + id: 'scanTotalSegments', + title: 'Total Segments', + type: 'short-input', + placeholder: '4', + condition: { field: 'operation', value: 'scan' }, + mode: 'advanced', + }, + { + id: 'putReturnValues', + title: 'Return Values', + type: 'dropdown', + options: [ + { label: 'None (default)', id: 'NONE' }, + { label: 'Replaced item (ALL_OLD)', id: 'ALL_OLD' }, + ], + value: () => 'NONE', + condition: { field: 'operation', value: 'put' }, + mode: 'advanced', + }, + { + id: 'updateReturnValues', + title: 'Return Values', + type: 'dropdown', + options: [ + { label: 'All new attributes (default)', id: 'ALL_NEW' }, + { label: 'Updated new attributes', id: 'UPDATED_NEW' }, + { label: 'All old attributes', id: 'ALL_OLD' }, + { label: 'Updated old attributes', id: 'UPDATED_OLD' }, + { label: 'None', id: 'NONE' }, + ], + value: () => 'ALL_NEW', + condition: { field: 'operation', value: 'update' }, + mode: 'advanced', + }, + { + id: 'deleteReturnValues', + title: 'Return Values', + type: 'dropdown', + options: [ + { label: 'None (default)', id: 'NONE' }, + { label: 'Deleted item (ALL_OLD)', id: 'ALL_OLD' }, + ], + value: () => 'NONE', + condition: { field: 'operation', value: 'delete' }, + mode: 'advanced', + }, ], tools: { access: [ @@ -661,6 +772,9 @@ Return ONLY the expression - no explanations.`, if (params.consistentRead === 'true' || params.consistentRead === true) { result.consistentRead = true } + result.projectionExpression = params.getProjectionExpression || undefined + const getNames = parseJson(params.getExpressionAttributeNames, 'expressionAttributeNames') + if (getNames !== undefined) result.expressionAttributeNames = getNames } if (op === 'put') { @@ -681,6 +795,7 @@ Return ONLY the expression - no explanations.`, 'expressionAttributeValues' ) if (values !== undefined) result.expressionAttributeValues = values + if (params.putReturnValues === 'ALL_OLD') result.returnValues = 'ALL_OLD' } if (op === 'query') { @@ -694,13 +809,19 @@ Return ONLY the expression - no explanations.`, 'expressionAttributeValues' ) if (values !== undefined) result.expressionAttributeValues = values - if (params.indexName) result.indexName = params.indexName + result.indexName = params.indexName || undefined if (params.queryLimit) result.limit = Number.parseInt(String(params.queryLimit), 10) const esk = parseJson(params.queryExclusiveStartKey, 'exclusiveStartKey') if (esk !== undefined) result.exclusiveStartKey = esk - if (params.scanIndexForward === 'false' || params.scanIndexForward === false) { - result.scanIndexForward = false - } + result.scanIndexForward = + params.scanIndexForward === 'false' || params.scanIndexForward === false + ? false + : undefined + result.projectionExpression = params.queryProjectionExpression || undefined + result.consistentRead = + params.queryConsistentRead === 'true' || params.queryConsistentRead === true + ? true + : undefined } if (op === 'scan') { @@ -725,6 +846,21 @@ Return ONLY the expression - no explanations.`, if (lim) result.limit = Number.parseInt(String(lim), 10) const esk = parseJson(params.scanExclusiveStartKey, 'exclusiveStartKey') if (esk !== undefined) result.exclusiveStartKey = esk + result.indexName = params.scanIndexName || undefined + result.consistentRead = + params.scanConsistentRead === 'true' || params.scanConsistentRead === true + ? true + : undefined + if ( + params.scanSegment !== undefined && + params.scanSegment !== null && + params.scanSegment !== '' + ) { + result.segment = Number.parseInt(String(params.scanSegment), 10) + } + if (params.scanTotalSegments) { + result.totalSegments = Number.parseInt(String(params.scanTotalSegments), 10) + } } if (op === 'update') { @@ -746,6 +882,7 @@ Return ONLY the expression - no explanations.`, if (values !== undefined) result.expressionAttributeValues = values if (params.updateConditionExpression) result.conditionExpression = params.updateConditionExpression + if (params.updateReturnValues) result.returnValues = params.updateReturnValues } if (op === 'delete') { @@ -768,6 +905,7 @@ Return ONLY the expression - no explanations.`, 'expressionAttributeValues' ) if (values !== undefined) result.expressionAttributeValues = values + if (params.deleteReturnValues === 'ALL_OLD') result.returnValues = 'ALL_OLD' } return result @@ -840,6 +978,26 @@ Return ONLY the expression - no explanations.`, type: 'string', description: 'Sort order for query: true for ascending, false for descending', }, + getProjectionExpression: { type: 'string', description: 'Attributes to retrieve in get' }, + getExpressionAttributeNames: { + type: 'json', + description: 'Attribute name mappings for get projection', + }, + queryProjectionExpression: { type: 'string', description: 'Attributes to retrieve in query' }, + queryConsistentRead: { type: 'string', description: 'Use strongly consistent read for query' }, + scanIndexName: { type: 'string', description: 'Secondary index name for scan' }, + scanConsistentRead: { type: 'string', description: 'Use strongly consistent read for scan' }, + scanSegment: { type: 'number', description: 'Parallel scan segment number' }, + scanTotalSegments: { type: 'number', description: 'Parallel scan total segment count' }, + putReturnValues: { type: 'string', description: 'Return values for put (NONE or ALL_OLD)' }, + updateReturnValues: { + type: 'string', + description: 'Return values for update (ALL_NEW, UPDATED_NEW, ALL_OLD, UPDATED_OLD, NONE)', + }, + deleteReturnValues: { + type: 'string', + description: 'Return values for delete (NONE or ALL_OLD)', + }, }, outputs: { message: { @@ -858,6 +1016,14 @@ Return ONLY the expression - no explanations.`, type: 'number', description: 'Number of items returned', }, + scannedCount: { + type: 'number', + description: 'Number of items evaluated by query/scan before the filter expression', + }, + attributes: { + type: 'json', + description: 'Replaced (put) or deleted (delete) item when Return Values is ALL_OLD', + }, lastEvaluatedKey: { type: 'json', description: diff --git a/apps/sim/blocks/blocks/ec2.ts b/apps/sim/blocks/blocks/ec2.ts index 3ed4dcf093a..9bba35d6f20 100644 --- a/apps/sim/blocks/blocks/ec2.ts +++ b/apps/sim/blocks/blocks/ec2.ts @@ -766,7 +766,7 @@ Return ONLY the JSON array, no explanation.`, instances: { type: 'array', description: - 'Instances (describe_instances) or per-instance state transitions (start, stop, terminate)', + 'Instances (describe_instances: instanceId, instanceType, state, stateReason, launchTime, IPs, vpcId, subnetId, platform, architecture, iamInstanceProfileArn, securityGroups, blockDeviceMappings, networkInterfaces, metadataHttpTokens, tags) or per-instance state transitions (start, stop, terminate)', }, instanceStatuses: { type: 'array', diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index ef6d44737c1..11708998be6 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -3,6 +3,7 @@ import { ChartBarIcon } from '@/components/icons' import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model-fallbacks' import type { BlockConfig, ParamType } from '@/blocks/types' import { + BEDROCK_SAVED_CREDENTIAL_INPUTS, getModelOptions, getProviderCredentialSubBlocks, getSerializedModelProviderId, @@ -170,7 +171,7 @@ export const EvaluatorBlock: BlockConfig = { defaultValue: 'claude-sonnet-5-5', options: getModelOptions, }, - ...getProviderCredentialSubBlocks(), + ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }), getModelFallbackSubBlock(), { id: 'temperature', @@ -277,6 +278,7 @@ export const EvaluatorBlock: BlockConfig = { }, model: { type: 'string' as ParamType, description: 'AI model to use' }, ...PROVIDER_CREDENTIAL_INPUTS, + ...BEDROCK_SAVED_CREDENTIAL_INPUTS, ...MODEL_FALLBACK_INPUTS, temperature: { type: 'number' as ParamType, diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts index af7e410a04c..924929e8fb0 100644 --- a/apps/sim/blocks/blocks/gmail.ts +++ b/apps/sim/blocks/blocks/gmail.ts @@ -312,6 +312,15 @@ Return ONLY the email body - no explanations, no extra text.`, mode: 'advanced', required: false, }, + { + id: 'replyTo', + title: 'Reply-To', + type: 'short-input', + placeholder: 'Address replies should go to (e.g., support@example.com)', + condition: { field: 'operation', value: ['send_gmail', 'draft_gmail', 'edit_draft_gmail'] }, + mode: 'advanced', + required: false, + }, // Label/folder selector (basic mode) { id: 'folder', @@ -382,9 +391,19 @@ Return ONLY the search query - no explanations, no extra text.`, id: 'maxResults', title: 'Max Results', type: 'short-input', - placeholder: 'Maximum number of results (default: 10)', + placeholder: + 'Maximum number of results (read: default 1, max 10; search: default 100, max 500)', condition: { field: 'operation', value: ['search_gmail', 'read_gmail'] }, }, + { + id: 'pageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'nextPageToken from a previous search', + condition: { field: 'operation', value: 'search_gmail' }, + mode: 'advanced', + required: false, + }, // Move Email Fields { id: 'moveMessageId', @@ -603,10 +622,11 @@ Return ONLY the search query - no explanations, no extra text.`, threadId: { type: 'string', description: 'Thread ID to reply to (for threading)' }, replyToMessageId: { type: 'string', - description: 'Gmail message ID to reply to (use "id" field from results, not "messageId")', + description: 'Gmail message ID to reply to (use "id" field from results, not "rfcMessageId")', }, cc: { type: 'string', description: 'CC recipients (comma-separated)' }, bcc: { type: 'string', description: 'BCC recipients (comma-separated)' }, + replyTo: { type: 'string', description: 'Reply-To address for the outgoing email' }, attachments: { type: 'array', description: 'Files to attach (canonical param)' }, // Read operation inputs folder: { type: 'string', description: 'Gmail folder (canonical param)' }, @@ -616,6 +636,10 @@ Return ONLY the search query - no explanations, no extra text.`, // Search operation inputs query: { type: 'string', description: 'Search query' }, maxResults: { type: 'number', description: 'Maximum results' }, + pageToken: { + type: 'string', + description: 'Search page token (nextPageToken from a prior search)', + }, // Move operation inputs moveMessageId: { type: 'string', description: 'Message ID to move' }, addLabelIds: { type: 'string', description: 'Label IDs to add (canonical param)' }, @@ -632,7 +656,11 @@ Return ONLY the search query - no explanations, no extra text.`, outputs: { // Tool outputs content: { type: 'string', description: 'Response content' }, - metadata: { type: 'json', description: 'Email metadata' }, + metadata: { + type: 'json', + description: + 'Email metadata (id, threadId, labelIds, from, to, cc, bcc, replyTo, subject, date, rfcMessageId, inReplyTo, references, snippet, internalDate, hasAttachments, attachmentCount); search/multi-read returns results[] with the same header fields plus nextPageToken and resultSizeEstimate for search', + }, attachments: { type: 'file[]', description: 'Email attachments array' }, // Trigger outputs email_id: { type: 'string', description: 'Gmail message ID' }, @@ -655,6 +683,223 @@ Return ONLY the search query - no explanations, no extra text.`, }, } +const LABEL_WRITE_OPERATIONS = ['create_label_gmail', 'update_label_gmail'] +const LABEL_TARGET_OPERATIONS = ['update_label_gmail', 'delete_label_gmail'] +const THREAD_TARGET_OPERATIONS = ['get_thread_gmail', 'trash_thread_gmail', 'untrash_thread_gmail'] +const LIST_OPERATIONS = ['list_drafts_gmail', 'list_threads_gmail'] +const PAGINATED_OPERATIONS = ['search_gmail', ...LIST_OPERATIONS] +const LABEL_TARGET_FIELD = ['labelTarget', 'manualLabelTarget'] as const + +/** Operations only the V2 block exposes; their tools exist only in a `_v2` version. */ +const GMAIL_V2_OPERATION_TOOLS: Record = { + list_labels_gmail: 'gmail_list_labels_v2', + create_label_gmail: 'gmail_create_label_v2', + update_label_gmail: 'gmail_update_label_v2', + delete_label_gmail: 'gmail_delete_label_v2', + list_drafts_gmail: 'gmail_list_drafts_v2', + get_draft_gmail: 'gmail_get_draft_v2', + delete_draft_gmail: 'gmail_delete_draft_v2', + list_threads_gmail: 'gmail_list_threads_v2', + get_thread_gmail: 'gmail_get_thread_v2', + trash_thread_gmail: 'gmail_trash_thread_v2', + untrash_thread_gmail: 'gmail_untrash_thread_v2', +} + +function selectGmailV2ToolId(params: Record): string { + const operation = typeof params.operation === 'string' ? params.operation : '' + return ( + (Object.hasOwn(GMAIL_V2_OPERATION_TOOLS, operation) && GMAIL_V2_OPERATION_TOOLS[operation]) || + selectGmailToolId(params) + ) +} + +const GMAIL_V2_OPERATION_OPTIONS = [ + { label: 'List Labels', id: 'list_labels_gmail' }, + { label: 'Create Label', id: 'create_label_gmail' }, + { label: 'Update Label', id: 'update_label_gmail' }, + { label: 'Delete Label', id: 'delete_label_gmail' }, + { label: 'List Drafts', id: 'list_drafts_gmail' }, + { label: 'Get Draft', id: 'get_draft_gmail' }, + { label: 'Delete Draft', id: 'delete_draft_gmail' }, + { label: 'List Threads', id: 'list_threads_gmail' }, + { label: 'Get Thread', id: 'get_thread_gmail' }, + { label: 'Trash Thread', id: 'trash_thread_gmail' }, + { label: 'Untrash Thread', id: 'untrash_thread_gmail' }, +] + +const GMAIL_V2_SUBBLOCKS: BlockConfig['subBlocks'] = [ + { + id: 'labelTarget', + title: 'Label', + type: 'folder-selector', + canonicalParamId: 'labelId', + serviceId: 'gmail', + selectorKey: 'gmail.labels', + requiredScopes: getScopesForService('gmail'), + placeholder: 'Select label', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: LABEL_TARGET_OPERATIONS }, + required: true, + }, + { + id: 'manualLabelTarget', + title: 'Label ID', + type: 'short-input', + canonicalParamId: 'labelId', + placeholder: 'Enter label ID (e.g., Label_123)', + dependsOn: ['credential'], + mode: 'advanced', + condition: { field: 'operation', value: LABEL_TARGET_OPERATIONS }, + required: true, + }, + { + id: 'labelName', + title: 'Label Name', + type: 'short-input', + placeholder: 'Label display name (e.g., Clients/Acme)', + condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS }, + required: { field: 'operation', value: 'create_label_gmail' }, + }, + { + id: 'messageListVisibility', + title: 'Message List Visibility', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Show', id: 'show' }, + { label: 'Hide', id: 'hide' }, + ], + value: () => '', + condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS }, + mode: 'advanced', + required: false, + }, + { + id: 'labelListVisibility', + title: 'Label List Visibility', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Show', id: 'labelShow' }, + { label: 'Show if unread', id: 'labelShowIfUnread' }, + { label: 'Hide', id: 'labelHide' }, + ], + value: () => '', + condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS }, + mode: 'advanced', + required: false, + }, + { + id: 'targetThreadId', + title: 'Thread ID', + type: 'short-input', + placeholder: 'ID of the thread (threadId from Read, Search, or List Threads)', + condition: { field: 'operation', value: THREAD_TARGET_OPERATIONS }, + required: true, + }, + { + id: 'threadFormat', + title: 'Format', + type: 'dropdown', + options: [ + { label: 'Full', id: 'full' }, + { label: 'Metadata', id: 'metadata' }, + { label: 'Minimal', id: 'minimal' }, + ], + value: () => 'full', + condition: { field: 'operation', value: 'get_thread_gmail' }, + mode: 'advanced', + required: false, + }, + { + id: 'listQuery', + title: 'Search Query', + type: 'short-input', + placeholder: 'Optional Gmail search filter (e.g., from:alice is:unread)', + condition: { field: 'operation', value: LIST_OPERATIONS }, + required: false, + wandConfig: { + enabled: true, + prompt: `Generate a Gmail search query based on the user's request. +Gmail search supports operators like: +- from: to: subject: has:attachment +- is:unread is:starred is:important +- before: after: older: newer: +- filename: label: category: + +Return ONLY the search query - no explanations, no extra text.`, + placeholder: 'Describe which drafts or threads you want...', + }, + }, + { + id: 'threadLabelIds', + title: 'Label IDs', + type: 'short-input', + placeholder: 'Comma-separated label IDs (e.g., INBOX,Label_123)', + condition: { field: 'operation', value: 'list_threads_gmail' }, + mode: 'advanced', + required: false, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gmail label IDs based on the user's request. +System labels are uppercase (INBOX, SENT, DRAFT, SPAM, TRASH, STARRED, IMPORTANT, UNREAD); custom labels look like Label_123. + +Return ONLY the comma-separated label IDs - no explanations, no extra text.`, + placeholder: 'Describe which labels to filter by...', + }, + }, +] + +function buildGmailV2SubBlocks(): BlockConfig['subBlocks'] { + return GmailBlock.subBlocks.flatMap((subBlock) => { + switch (subBlock.id) { + case 'operation': + return [ + { + ...subBlock, + options: [ + ...(Array.isArray(subBlock.options) ? subBlock.options : []), + ...GMAIL_V2_OPERATION_OPTIONS, + ], + }, + ] + case 'draftId': + return [ + { + ...subBlock, + placeholder: 'ID of the draft (from List Drafts)', + condition: { + field: 'operation', + value: ['edit_draft_gmail', 'get_draft_gmail', 'delete_draft_gmail'], + }, + }, + ] + case 'maxResults': + return [ + { + ...subBlock, + placeholder: + 'Maximum number of results (read: default 1, max 10; search and lists: default 100, max 500)', + condition: { field: 'operation', value: ['read_gmail', ...PAGINATED_OPERATIONS] }, + }, + ] + case 'pageToken': + return [ + { + ...subBlock, + placeholder: 'nextPageToken from a previous search or list', + condition: { field: 'operation', value: PAGINATED_OPERATIONS }, + }, + ] + case 'manualLabelId': + return [subBlock, ...GMAIL_V2_SUBBLOCKS] + default: + return [subBlock] + } + }) +} + export const GmailV2Block: BlockConfig = { ...GmailBlock, sunset: undefined, @@ -662,6 +907,33 @@ export const GmailV2Block: BlockConfig = { name: 'Gmail', hideFromToolbar: false, integrationType: IntegrationType.Email, + canvasPresentation: GmailBlock.canvasPresentation && { + ...GmailBlock.canvasPresentation, + sentences: { + byOperation: { + ...GmailBlock.canvasPresentation.sentences?.byOperation, + list_labels_gmail: ['List labels'], + create_label_gmail: [{ text: 'Create label', field: 'labelName', core: true }], + update_label_gmail: [ + { text: 'Update label', field: LABEL_TARGET_FIELD, core: true }, + { text: ', renaming it to', field: 'labelName' }, + ], + delete_label_gmail: [{ text: 'Delete label', field: LABEL_TARGET_FIELD, core: true }], + list_drafts_gmail: ['List drafts', { text: 'matching', field: 'listQuery' }], + get_draft_gmail: [{ text: 'Get draft', field: 'draftId', core: true }], + delete_draft_gmail: [{ text: 'Delete draft', field: 'draftId', core: true }], + list_threads_gmail: ['List threads', { text: 'matching', field: 'listQuery' }], + get_thread_gmail: [{ text: 'Get thread', field: 'targetThreadId', core: true }], + trash_thread_gmail: [ + { text: 'Move thread', field: 'targetThreadId', core: true, after: 'to trash' }, + ], + untrash_thread_gmail: [ + { text: 'Restore thread', field: 'targetThreadId', core: true, after: 'from trash' }, + ], + }, + }, + }, + subBlocks: buildGmailV2SubBlocks(), tools: { ...GmailBlock.tools, access: [ @@ -679,48 +951,204 @@ export const GmailV2Block: BlockConfig = { 'gmail_add_label_v2', 'gmail_remove_label_v2', 'gmail_list_labels_v2', + 'gmail_create_label_v2', + 'gmail_update_label_v2', + 'gmail_delete_label_v2', + 'gmail_list_drafts_v2', + 'gmail_get_draft_v2', + 'gmail_delete_draft_v2', + 'gmail_list_threads_v2', + 'gmail_get_thread_v2', + 'gmail_trash_thread_v2', + 'gmail_untrash_thread_v2', ], config: { ...GmailBlock.tools?.config, tool: createVersionedToolSelector({ - baseToolSelector: selectGmailToolId, + baseToolSelector: selectGmailV2ToolId, suffix: '_v2', fallbackToolId: 'gmail_send_v2', }), + params: (params) => { + const mapped = GmailBlock.tools?.config?.params?.(params) ?? params + const { + labelName, + targetThreadId, + threadFormat, + listQuery, + threadLabelIds, + messageListVisibility, + labelListVisibility, + ...rest + } = mapped + if (LABEL_WRITE_OPERATIONS.includes(rest.operation)) { + rest.name = labelName || undefined + rest.messageListVisibility = messageListVisibility || undefined + rest.labelListVisibility = labelListVisibility || undefined + } + if (THREAD_TARGET_OPERATIONS.includes(rest.operation)) { + rest.threadId = targetThreadId + rest.format = threadFormat || undefined + } + if (LIST_OPERATIONS.includes(rest.operation)) { + rest.query = listQuery || undefined + rest.labelIds = threadLabelIds ? String(threadLabelIds).trim() : undefined + } + return rest + }, }, }, + inputs: { + ...GmailBlock.inputs, + labelId: { type: 'string', description: 'Label ID to update or delete (canonical param)' }, + labelName: { type: 'string', description: 'Label display name' }, + messageListVisibility: { + type: 'string', + description: 'Message list visibility for the label (show or hide)', + }, + labelListVisibility: { + type: 'string', + description: 'Label list visibility (labelShow, labelShowIfUnread, or labelHide)', + }, + targetThreadId: { type: 'string', description: 'Thread ID for thread operations' }, + threadFormat: { type: 'string', description: 'Thread format (full, metadata, or minimal)' }, + listQuery: { type: 'string', description: 'Search filter for listing drafts or threads' }, + threadLabelIds: { type: 'string', description: 'Comma-separated label IDs to filter threads' }, + }, outputs: { // V2 tool outputs (API-aligned) - id: { type: 'string', description: 'Gmail message ID' }, + id: { + type: 'string', + description: + 'Gmail message ID (thread ID for thread operations, label ID for label operations, draft ID for Get Draft)', + }, threadId: { type: 'string', description: 'Gmail thread ID' }, labelIds: { type: 'array', description: 'Email label IDs' }, from: { type: 'string', description: 'Sender' }, to: { type: 'string', description: 'To' }, + bcc: { type: 'string', description: 'Bcc recipients (only on messages you sent or drafted)' }, + replyTo: { type: 'string', description: 'Reply-To header' }, subject: { type: 'string', description: 'Subject' }, date: { type: 'string', description: 'Date' }, + rfcMessageId: { + type: 'string', + description: 'RFC Message-ID header (not the Gmail message id)', + }, + inReplyTo: { type: 'string', description: 'In-Reply-To header' }, + references: { type: 'string', description: 'References header' }, + snippet: { type: 'string', description: 'Short excerpt of the message text' }, + internalDate: { + type: 'string', + description: 'Gmail internal creation timestamp in epoch milliseconds', + }, body: { type: 'string', description: 'Email body text (best-effort)' }, - results: { type: 'json', description: 'Search/read summary results' }, + results: { + type: 'json', + description: + 'Search/read summary results [{id, threadId, subject, from, to, cc, bcc, replyTo, date, snippet, rfcMessageId, inReplyTo, references, internalDate}]', + }, + nextPageToken: { + type: 'string', + description: 'Token for the next page of results', + condition: { field: 'operation', value: PAGINATED_OPERATIONS }, + }, + resultSizeEstimate: { + type: 'number', + description: 'Estimated total number of results', + condition: { field: 'operation', value: PAGINATED_OPERATIONS }, + }, + name: { + type: 'string', + description: 'Label display name', + condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS }, + }, + type: { + type: 'string', + description: 'Label type (system or user)', + condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS }, + }, + messageListVisibility: { + type: 'string', + description: 'Message list visibility of the label', + condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS }, + }, + labelListVisibility: { + type: 'string', + description: 'Label list visibility of the label', + condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS }, + }, + labelId: { + type: 'string', + description: 'ID of the deleted label', + condition: { field: 'operation', value: 'delete_label_gmail' }, + }, + deleted: { + type: 'boolean', + description: 'Whether the label or draft was deleted', + condition: { field: 'operation', value: ['delete_label_gmail', 'delete_draft_gmail'] }, + }, + drafts: { + type: 'json', + description: 'Drafts [{id, messageId, threadId}]', + condition: { field: 'operation', value: 'list_drafts_gmail' }, + }, + threads: { + type: 'json', + description: 'Threads [{id, snippet, historyId}]', + condition: { field: 'operation', value: 'list_threads_gmail' }, + }, + messages: { + type: 'json', + description: + 'Thread messages [{id, threadId, labelIds, snippet, from, to, cc, bcc, replyTo, subject, date, rfcMessageId, inReplyTo, references, internalDate, body}]', + condition: { field: 'operation', value: 'get_thread_gmail' }, + }, + historyId: { + type: 'string', + description: 'ID of the last history record that modified the thread', + condition: { field: 'operation', value: 'get_thread_gmail' }, + }, + trashed: { + type: 'boolean', + description: 'Whether the thread was moved to trash', + condition: { field: 'operation', value: 'trash_thread_gmail' }, + }, + untrashed: { + type: 'boolean', + description: 'Whether the thread was restored from trash', + condition: { field: 'operation', value: 'untrash_thread_gmail' }, + }, attachments: { type: 'file[]', description: 'Downloaded attachments (if enabled)' }, // Draft-specific outputs draftId: { type: 'string', description: 'Draft ID', - condition: { field: 'operation', value: ['draft_gmail', 'edit_draft_gmail'] }, + condition: { + field: 'operation', + value: ['draft_gmail', 'edit_draft_gmail', 'delete_draft_gmail'], + }, }, messageId: { type: 'string', description: 'Gmail message ID for the draft', - condition: { field: 'operation', value: ['draft_gmail', 'edit_draft_gmail'] }, + condition: { + field: 'operation', + value: ['draft_gmail', 'edit_draft_gmail', 'get_draft_gmail'], + }, }, // Trigger outputs (unchanged) email_id: { type: 'string', description: 'Gmail message ID' }, thread_id: { type: 'string', description: 'Gmail thread ID' }, - cc: { type: 'string', description: 'CC recipients (comma-separated)' }, + cc: { type: 'string', description: 'Cc recipients (raw Cc header, comma-separated)' }, body_text: { type: 'string', description: 'Plain text email body' }, body_html: { type: 'string', description: 'HTML email body' }, - labels: { type: 'string', description: 'Email labels (comma-separated)' }, + labels: { + type: 'json', + description: + 'Email labels (trigger, comma-separated); for List Labels, the label array [{id, name, type, messageListVisibility, labelListVisibility}]', + }, has_attachments: { type: 'boolean', description: 'Whether email has attachments' }, raw_email: { type: 'json', description: 'Complete raw email data from Gmail API (if enabled)' }, timestamp: { type: 'string', description: 'Event timestamp' }, diff --git a/apps/sim/blocks/blocks/google_calendar.ts b/apps/sim/blocks/blocks/google_calendar.ts index 0a4c73828b2..c3ff09602b3 100644 --- a/apps/sim/blocks/blocks/google_calendar.ts +++ b/apps/sim/blocks/blocks/google_calendar.ts @@ -338,6 +338,58 @@ Return ONLY the RRULE string - no explanations, no extra text.`, condition: { field: 'operation', value: ['create', 'update'] }, mode: 'advanced', }, + { + id: 'optionalAttendees', + title: 'Optional Attendees (comma-separated emails)', + type: 'short-input', + placeholder: 'alex@example.com, sam@example.com', + condition: { field: 'operation', value: 'create' }, + mode: 'advanced', + }, + { + id: 'visibility', + title: 'Visibility', + type: 'dropdown', + condition: { field: 'operation', value: ['create', 'update'] }, + mode: 'advanced', + options: [ + { label: 'Calendar default (unchanged on update)', id: '' }, + { label: 'Default', id: 'default' }, + { label: 'Public', id: 'public' }, + { label: 'Private', id: 'private' }, + { label: 'Confidential', id: 'confidential' }, + ], + value: () => '', + }, + { + id: 'transparency', + title: 'Show As', + type: 'dropdown', + condition: { field: 'operation', value: ['create', 'update'] }, + mode: 'advanced', + options: [ + { label: 'Calendar default (unchanged on update)', id: '' }, + { label: 'Busy', id: 'opaque' }, + { label: 'Free', id: 'transparent' }, + ], + value: () => '', + }, + { + id: 'colorId', + title: 'Color ID', + type: 'short-input', + placeholder: '1-11', + condition: { field: 'operation', value: ['create', 'update'] }, + mode: 'advanced', + }, + { + id: 'reminders', + title: 'Reminders', + type: 'long-input', + placeholder: '[{"method":"popup","minutes":10},{"method":"email","minutes":60}]', + condition: { field: 'operation', value: ['create', 'update'] }, + mode: 'advanced', + }, { id: 'timeMin', @@ -400,6 +452,22 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, ], value: () => 'startTime', }, + { + id: 'updatedMin', + title: 'Modified Since', + type: 'short-input', + placeholder: '2025-06-03T00:00:00Z', + condition: { field: 'operation', value: 'list' }, + mode: 'advanced', + }, + { + id: 'listTimeZone', + title: 'Response Time Zone', + type: 'short-input', + placeholder: 'America/Los_Angeles (default: calendar time zone)', + condition: { field: 'operation', value: 'list' }, + mode: 'advanced', + }, { id: 'pageToken', title: 'Page Token', @@ -991,10 +1059,25 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, processedParams.maxResults = Number.parseInt(processedParams.maxResults, 10) } + for (const key of ['visibility', 'transparency', 'colorId', 'reminders', 'updatedMin']) { + if (processedParams[key] === '') { + processedParams[key] = undefined + } + } + if (processedParams.minAccessRole === '') { processedParams.minAccessRole = undefined } + const listTimeZone = processedParams.listTimeZone + processedParams.listTimeZone = undefined + if (operation === 'list') { + processedParams.timeZone = + typeof listTimeZone === 'string' && listTimeZone.trim() + ? listTimeZone.trim() + : undefined + } + return { oauthCredential, ...processedParams, @@ -1016,6 +1099,16 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, recurrence: { type: 'string', description: 'Recurrence rule (RRULE)' }, addGoogleMeet: { type: 'boolean', description: 'Attach a Google Meet link' }, timeZone: { type: 'string', description: 'Time zone (IANA name)' }, + optionalAttendees: { type: 'string', description: 'Optional attendee email list' }, + visibility: { + type: 'string', + description: 'Event visibility (default, public, private, confidential)', + }, + transparency: { type: 'string', description: 'Show as busy (opaque) or free (transparent)' }, + colorId: { type: 'string', description: 'Event color ID' }, + reminders: { type: 'json', description: 'Reminder overrides [{ method, minutes }]' }, + updatedMin: { type: 'string', description: 'Only events modified since this time' }, + listTimeZone: { type: 'string', description: 'Time zone used for List Events response times' }, timeMin: { type: 'string', description: 'Start time filter' }, timeMax: { type: 'string', description: 'End time filter' }, @@ -1107,6 +1200,41 @@ export const GoogleCalendarV2Block: BlockConfig = { id: { type: 'string', description: 'Event or calendar ID' }, htmlLink: { type: 'string', description: 'Event link' }, hangoutLink: { type: 'string', description: 'Google Meet link' }, + conferenceData: { + type: 'json', + description: + 'Conference details: conferenceId, conferenceSolution { name }, entryPoints [{ entryPointType (video, phone, sip, more), uri, label, pin, meetingCode, passcode }]', + }, + recurringEventId: { + type: 'string', + description: 'Recurring series ID (for an instance of a recurring event)', + }, + originalStartTime: { + type: 'json', + description: 'Original start time of a recurring event instance', + }, + reminders: { + type: 'json', + description: 'Reminders: useDefault and overrides [{ method, minutes }]', + }, + visibility: { + type: 'string', + description: 'Visibility: default, public, private, or confidential', + }, + transparency: { type: 'string', description: 'Show as: opaque (busy) or transparent (free)' }, + colorId: { type: 'string', description: 'Event color ID' }, + eventType: { + type: 'string', + description: + 'Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, birthday', + }, + iCalUID: { type: 'string', description: 'iCalendar UID' }, + created: { type: 'string', description: 'Event creation time' }, + updated: { type: 'string', description: 'Event last modified time' }, + attachments: { + type: 'json', + description: 'File attachments [{ fileUrl, title, mimeType, iconLink, fileId }]', + }, status: { type: 'string', description: 'Event status' }, summary: { type: 'string', description: 'Event or calendar title' }, description: { type: 'string', description: 'Event or calendar description' }, @@ -1114,12 +1242,24 @@ export const GoogleCalendarV2Block: BlockConfig = { recurrence: { type: 'json', description: 'Recurrence rules (RRULE)' }, start: { type: 'json', description: 'Event start' }, end: { type: 'json', description: 'Event end' }, - attendees: { type: 'json', description: 'Event attendees' }, - creator: { type: 'json', description: 'Event creator' }, - organizer: { type: 'json', description: 'Event organizer' }, + attendees: { + type: 'json', + description: + 'Event attendees [{ email, displayName, responseStatus (needsAction, declined, tentative, accepted), optional, organizer, self, resource, comment, additionalGuests }]', + }, + creator: { type: 'json', description: 'Event creator { email, displayName, self }' }, + organizer: { type: 'json', description: 'Event organizer { email, displayName, self }' }, responseStatus: { type: 'string', description: 'Your RSVP response (respond operation)' }, comment: { type: 'string', description: 'Your RSVP note (respond operation)' }, - events: { type: 'json', description: 'List of events (list operation)' }, + events: { + type: 'json', + description: + 'List of events (list operation); each has the same fields as a single event (id, summary, start, end, attendees, organizer, hangoutLink, conferenceData, recurrence, reminders, visibility, status, htmlLink, and more)', + }, + nextSyncToken: { + type: 'string', + description: 'Sync token for incremental sync (list operation, last page)', + }, eventId: { type: 'string', description: 'Deleted event ID' }, deleted: { type: 'boolean', description: 'Whether deletion/removal was successful' }, instances: { type: 'json', description: 'List of recurring event instances' }, diff --git a/apps/sim/blocks/blocks/google_contacts.ts b/apps/sim/blocks/blocks/google_contacts.ts index 12efdfa7c1d..2b1a04d85f3 100644 --- a/apps/sim/blocks/blocks/google_contacts.ts +++ b/apps/sim/blocks/blocks/google_contacts.ts @@ -155,6 +155,26 @@ export const GoogleContactsBlock: BlockConfig = { condition: { field: 'operation', value: ['create', 'update'] }, mode: 'advanced', }, + { + id: 'birthday', + title: 'Birthday', + type: 'short-input', + placeholder: '1990-04-15 or 04-15', + condition: { field: 'operation', value: ['create', 'update'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a birthday into a date. +Use YYYY-MM-DD when the year is known, or MM-DD when it is not. + +Examples: +- "April 15th 1990" -> 1990-04-15 +- "March 3" -> 03-03 + +Return ONLY the date - no explanations, no extra text.`, + placeholder: 'Describe the birthday...', + }, + }, // Get / Update / Delete Fields { @@ -277,6 +297,7 @@ export const GoogleContactsBlock: BlockConfig = { organization: { type: 'string', description: 'Organization name' }, jobTitle: { type: 'string', description: 'Job title' }, notes: { type: 'string', description: 'Notes' }, + birthday: { type: 'string', description: 'Birthday (YYYY-MM-DD or MM-DD)' }, // Get/Update/Delete inputs resourceName: { type: 'string', description: 'Contact resource name' }, @@ -292,7 +313,11 @@ export const GoogleContactsBlock: BlockConfig = { }, outputs: { content: { type: 'string', description: 'Operation response content' }, - metadata: { type: 'json', description: 'Contact or contacts metadata' }, + metadata: { + type: 'json', + description: + 'Contact (resourceName, etag, displayName, givenName, familyName, middleName, emails, phones, organizations, addresses, biographies, urls, photos, birthdays, events, nicknames, relations, userDefined, contactGroupResourceNames), list {contacts, nextPageToken, totalItems}, or search {contacts}', + }, }, } diff --git a/apps/sim/blocks/blocks/google_docs.ts b/apps/sim/blocks/blocks/google_docs.ts index 8fe4799dc14..a126d9312bf 100644 --- a/apps/sim/blocks/blocks/google_docs.ts +++ b/apps/sim/blocks/blocks/google_docs.ts @@ -334,6 +334,14 @@ Return ONLY the text to insert - no explanations, no extra text.`, condition: { field: 'operation', value: 'replace_text' }, description: 'When on, only case-sensitive matches are replaced.', }, + { + id: 'searchByRegex', + title: 'Use Regular Expression', + type: 'switch', + condition: { field: 'operation', value: 'replace_text' }, + description: 'When on, the Find text is treated as a regular expression.', + mode: 'advanced', + }, // Insert Table fields { id: 'rows', @@ -483,6 +491,21 @@ Return ONLY the text to insert - no explanations, no extra text.`, { label: 'Star / Circle / Square', id: 'BULLET_STAR_CIRCLE_SQUARE' }, { label: 'Numbered: Decimal / Alpha / Roman', id: 'NUMBERED_DECIMAL_ALPHA_ROMAN' }, { label: 'Numbered: Decimal Nested', id: 'NUMBERED_DECIMAL_NESTED' }, + { label: 'Diamond X / Arrow 3D / Square', id: 'BULLET_DIAMONDX_ARROW3D_SQUARE' }, + { label: 'Arrow 3D / Circle / Square', id: 'BULLET_ARROW3D_CIRCLE_SQUARE' }, + { + label: 'Numbered: Decimal / Alpha / Roman (parens)', + id: 'NUMBERED_DECIMAL_ALPHA_ROMAN_PARENS', + }, + { + label: 'Numbered: Upper Alpha / Alpha / Roman', + id: 'NUMBERED_UPPERALPHA_ALPHA_ROMAN', + }, + { + label: 'Numbered: Upper Roman / Upper Alpha / Decimal', + id: 'NUMBERED_UPPERROMAN_UPPERALPHA_DECIMAL', + }, + { label: 'Numbered: Zero Decimal / Alpha / Roman', id: 'NUMBERED_ZERODECIMAL_ALPHA_ROMAN' }, ], condition: { field: 'operation', value: 'create_paragraph_bullets' }, }, @@ -510,6 +533,48 @@ Return ONLY the text to insert - no explanations, no extra text.`, placeholder: 'Name of the named range(s) to delete', condition: { field: 'operation', value: 'delete_named_range' }, }, + // Read options + { + id: 'includeTabsContent', + title: 'Read All Tabs', + type: 'switch', + condition: { field: 'operation', value: 'read' }, + description: + 'When on, reads every tab of the document instead of only the first tab, and returns each tab separately.', + mode: 'advanced', + }, + { + id: 'suggestionsViewMode', + title: 'Suggested Edits', + type: 'dropdown', + options: [ + { label: 'Default (based on access)', id: '' }, + { label: 'Show inline', id: 'SUGGESTIONS_INLINE' }, + { label: 'Preview with suggestions accepted', id: 'PREVIEW_SUGGESTIONS_ACCEPTED' }, + { label: 'Preview with suggestions rejected', id: 'PREVIEW_WITHOUT_SUGGESTIONS' }, + ], + condition: { field: 'operation', value: 'read' }, + mode: 'advanced', + }, + // Target tab (advanced) for multi-tab documents + { + id: 'tabId', + title: 'Tab ID', + type: 'short-input', + placeholder: 'Tab ID (empty: first tab; for Find & Replace: all tabs)', + condition: { + field: 'operation', + value: [ + 'write', + 'insert_text', + 'replace_text', + 'insert_table', + 'insert_image', + 'insert_page_break', + ], + }, + mode: 'advanced', + }, // Shared insertion index (advanced) for the insert operations { id: 'index', @@ -652,10 +717,22 @@ Return ONLY the text to insert - no explanations, no extra text.`, name: { type: 'string', description: 'Name for a created named range' }, namedRangeId: { type: 'string', description: 'ID of a named range to delete' }, namedRangeName: { type: 'string', description: 'Name of named range(s) to delete' }, + includeTabsContent: { type: 'boolean', description: 'Read every tab of the document' }, + suggestionsViewMode: { type: 'string', description: 'How suggested edits are returned' }, + tabId: { type: 'string', description: 'Target tab ID in a multi-tab document' }, + searchByRegex: { type: 'boolean', description: 'Treat find text as a regular expression' }, }, outputs: { content: { type: 'string', description: 'Document content' }, - metadata: { type: 'json', description: 'Document metadata' }, + metadata: { + type: 'json', + description: 'Document metadata (documentId, title, mimeType, url, revisionId on read)', + }, + tabs: { + type: 'json', + description: + 'Document tabs when Read All Tabs is on: [{tabId, title, parentTabId, index, nestingLevel, content}]', + }, updatedContent: { type: 'boolean', description: 'Content update status' }, occurrencesChanged: { type: 'number', diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index 5de854ce29d..183c02dcb2d 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -387,32 +387,26 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr id: 'query', title: 'Search Query', type: 'short-input', - placeholder: 'Search for specific files (e.g., name contains "report")', + placeholder: 'Filter by part of the file name (e.g., report)', condition: { field: 'operation', value: 'list' }, wandConfig: { enabled: true, - prompt: `Generate a Google Drive search query based on the user's description. -Use Google Drive query syntax: -- name contains 'term' - search by filename -- mimeType = 'type' - filter by file type -- modifiedTime > 'date' - filter by date -- 'email' in owners - filter by owner -- fullText contains 'term' - search file contents + prompt: `Extract a plain file-name search term from the user's description. +The term is matched against file names only (name contains). Do not use Google Drive query syntax, operators, or quotes; use the Search Files operation for advanced queries. Examples: -- "PDF files" -> mimeType = 'application/pdf' -- "files named report" -> name contains 'report' -- "spreadsheets modified today" -> mimeType = 'application/vnd.google-apps.spreadsheet' and modifiedTime > '2024-01-01' +- "files named report" -> report +- "the Q3 budget spreadsheet" -> Q3 budget -Return ONLY the query string - no explanations, no quotes around the whole thing, no extra text.`, - placeholder: 'Describe the files you want to find...', +Return ONLY the search term - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the file name you want to find...', }, }, { id: 'pageSize', title: 'Results Per Page', type: 'short-input', - placeholder: 'Number of results (default: 100, max: 100)', + placeholder: 'Number of results (default: 100, max: 1000)', condition: { field: 'operation', value: 'list' }, }, { @@ -423,6 +417,32 @@ Return ONLY the query string - no explanations, no quotes around the whole thing mode: 'advanced', condition: { field: 'operation', value: 'list' }, }, + { + id: 'listOrderBy', + title: 'Sort By', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Last modified (newest first)', id: 'modifiedTime desc' }, + { label: 'Last modified (oldest first)', id: 'modifiedTime' }, + { label: 'Name (A-Z)', id: 'name_natural' }, + { label: 'Name (Z-A)', id: 'name_natural desc' }, + { label: 'Created (newest first)', id: 'createdTime desc' }, + { label: 'Recently used', id: 'recency desc' }, + { label: 'Folders first, then name', id: 'folder,name_natural' }, + ], + value: () => '', + mode: 'advanced', + condition: { field: 'operation', value: 'list' }, + }, + { + id: 'listDriveId', + title: 'Shared Drive ID', + type: 'short-input', + placeholder: 'Restrict to one shared drive (default: My Drive and all shared drives)', + mode: 'advanced', + condition: { field: 'operation', value: 'list' }, + }, { id: 'downloadFileSelector', title: 'Select File', @@ -798,6 +818,57 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr placeholder: 'Describe why you are sharing this file...', }, }, + { + id: 'shareExpirationTime', + title: 'Access Expires', + type: 'short-input', + placeholder: 'Optional expiry, at most one year out (e.g., 2025-12-31T23:59:59Z)', + mode: 'advanced', + condition: { + field: 'operation', + value: 'share', + and: { field: 'shareType', value: ['user', 'group'] }, + }, + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: `Generate an RFC 3339 timestamp for when shared access should expire, based on the user's description. It must be in the future and no more than one year from now. +Examples: +- "in 30 days" -> a timestamp 30 days from now, e.g. 2025-02-14T00:00:00Z +- "end of the year" -> 2025-12-31T23:59:59Z + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe when access should expire (e.g., "in 2 weeks")...', + }, + }, + { + id: 'shareAllowFileDiscovery', + title: 'Discoverable in Search', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No (link only)', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'share', + and: { field: 'shareType', value: ['domain', 'anyone'] }, + }, + }, + { + id: 'shareMoveToNewOwnersRoot', + title: "Move to New Owner's My Drive", + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: 'share', + and: { field: 'role', value: 'owner' }, + }, + }, { id: 'unshareFileSelector', title: 'Select File', @@ -854,6 +925,14 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr condition: { field: 'operation', value: 'list_permissions' }, required: true, }, + { + id: 'permissionsPageSize', + title: 'Results Per Page', + type: 'short-input', + placeholder: 'Number of permissions (max: 100; default: all for My Drive files)', + mode: 'advanced', + condition: { field: 'operation', value: 'list_permissions' }, + }, { id: 'permissionsPageToken', title: 'Page Token', @@ -1023,7 +1102,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing id: 'searchPageSize', title: 'Results Per Page', type: 'short-input', - placeholder: 'Number of results (default: 100, max: 100)', + placeholder: 'Number of results (default: 100, max: 1000)', mode: 'advanced', condition: { field: 'operation', value: 'search' }, }, @@ -1035,6 +1114,32 @@ Return ONLY the query string - no explanations, no quotes around the whole thing mode: 'advanced', condition: { field: 'operation', value: 'search' }, }, + { + id: 'searchOrderBy', + title: 'Sort By', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Last modified (newest first)', id: 'modifiedTime desc' }, + { label: 'Last modified (oldest first)', id: 'modifiedTime' }, + { label: 'Name (A-Z)', id: 'name_natural' }, + { label: 'Name (Z-A)', id: 'name_natural desc' }, + { label: 'Created (newest first)', id: 'createdTime desc' }, + { label: 'Recently used', id: 'recency desc' }, + { label: 'Folders first, then name', id: 'folder,name_natural' }, + ], + value: () => '', + mode: 'advanced', + condition: { field: 'operation', value: 'search' }, + }, + { + id: 'searchDriveId', + title: 'Shared Drive ID', + type: 'short-input', + placeholder: 'Restrict to one shared drive (default: My Drive and all shared drives)', + mode: 'advanced', + condition: { field: 'operation', value: 'search' }, + }, { id: 'untrashFileSelector', title: 'Select File to Restore', @@ -1232,6 +1337,25 @@ Return ONLY the query string - no explanations, no quotes around the whole thing mode: 'advanced', condition: { field: 'operation', value: 'list_comments' }, }, + { + id: 'commentsStartModifiedTime', + title: 'Modified After', + type: 'short-input', + placeholder: 'Only comments modified after this time (e.g., 2025-01-01T00:00:00Z)', + mode: 'advanced', + condition: { field: 'operation', value: 'list_comments' }, + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: `Generate an RFC 3339 timestamp based on the user's description. +Examples: +- "last week" -> a timestamp 7 days before now, e.g. 2025-01-08T00:00:00Z +- "since January 1st 2025" -> 2025-01-01T00:00:00Z + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the cutoff time (e.g., "last 7 days")...', + }, + }, { id: 'includeDeleted', title: 'Include Deleted Comments', @@ -1453,6 +1577,15 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.` commentsPageToken, getContentExportMimeType, exportMimeType, + listOrderBy, + searchOrderBy, + listDriveId, + searchDriveId, + permissionsPageSize, + commentsStartModifiedTime, + shareExpirationTime, + shareAllowFileDiscovery, + shareMoveToNewOwnersRoot, ...rest } = params @@ -1555,6 +1688,7 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.` if (params.operation === 'search') effectivePageSize = searchPageSize else if (params.operation === 'list_revisions') effectivePageSize = revisionsPageSize else if (params.operation === 'list_comments') effectivePageSize = commentsPageSize + else if (params.operation === 'list_permissions') effectivePageSize = permissionsPageSize let effectivePageToken: string | undefined = pageToken if (params.operation === 'search') effectivePageToken = searchPageToken @@ -1564,6 +1698,36 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.` else if (params.operation !== 'list') effectivePageToken = undefined const effectiveQuery = params.operation === 'search' ? searchQuery : query + + const effectiveOrderBy = + params.operation === 'list' + ? listOrderBy + : params.operation === 'search' + ? searchOrderBy + : undefined + const effectiveDriveId = + params.operation === 'list' + ? listDriveId + : params.operation === 'search' + ? searchDriveId + : undefined + + const isShare = params.operation === 'share' + const isUserOrGroupShare = isShare && (shareType === 'user' || shareType === 'group') + const isDomainOrAnyoneShare = isShare && (shareType === 'domain' || shareType === 'anyone') + const allowFileDiscoveryValue = !isDomainOrAnyoneShare + ? undefined + : shareAllowFileDiscovery === 'true' || shareAllowFileDiscovery === true + ? true + : shareAllowFileDiscovery === 'false' || shareAllowFileDiscovery === false + ? false + : undefined + const moveToNewOwnersRootValue = + isShare && + rest.role === 'owner' && + (shareMoveToNewOwnersRoot === true || shareMoveToNewOwnersRoot === 'true') + ? true + : undefined const effectiveMimeType = params.operation === 'get_content' ? getContentExportMimeType @@ -1590,6 +1754,15 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.` includeRevisions: includeRevisionsValue, includeDeleted: includeDeletedValue, transferOwnership: rest.role === 'owner' ? true : undefined, + orderBy: effectiveOrderBy?.trim() || undefined, + driveId: effectiveDriveId?.trim() || undefined, + startModifiedTime: + params.operation === 'list_comments' + ? commentsStartModifiedTime?.trim() || undefined + : undefined, + expirationTime: isUserOrGroupShare ? shareExpirationTime?.trim() || undefined : undefined, + allowFileDiscovery: allowFileDiscoveryValue, + moveToNewOwnersRoot: moveToNewOwnersRootValue, ...rest, } }, @@ -1653,6 +1826,27 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.` anchor: { type: 'string', description: 'Anchor region for a new comment' }, includeDeleted: { type: 'string', description: 'Include deleted comments when listing' }, commentsPageSize: { type: 'string', description: 'Results per page for comments' }, + listOrderBy: { type: 'string', description: 'Sort order when listing files' }, + searchOrderBy: { type: 'string', description: 'Sort order when searching files' }, + listDriveId: { type: 'string', description: 'Shared drive to restrict listing to' }, + searchDriveId: { type: 'string', description: 'Shared drive to restrict search to' }, + permissionsPageSize: { type: 'string', description: 'Results per page for permissions' }, + commentsStartModifiedTime: { + type: 'string', + description: 'Only list comments modified after this RFC 3339 time', + }, + shareExpirationTime: { + type: 'string', + description: 'RFC 3339 time when a user or group permission expires', + }, + shareAllowFileDiscovery: { + type: 'string', + description: 'Whether a domain or anyone permission is discoverable in search', + }, + shareMoveToNewOwnersRoot: { + type: 'boolean', + description: "Move the file to the new owner's My Drive root when transferring ownership", + }, }, outputs: { file: { type: 'file', description: 'Downloaded file stored in execution files' }, @@ -1660,6 +1854,10 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.` metadata: { type: 'json', description: 'Complete file metadata (from download)' }, content: { type: 'string', description: 'File content as text' }, nextPageToken: { type: 'string', description: 'Token for fetching the next page of results' }, + incompleteSearch: { + type: 'boolean', + description: 'Whether a list or search was incomplete and some results may be missing', + }, permission: { type: 'json', description: 'Permission details' }, permissions: { type: 'json', description: 'List of permissions' }, user: { type: 'json', description: 'User information' }, diff --git a/apps/sim/blocks/blocks/google_forms.ts b/apps/sim/blocks/blocks/google_forms.ts index 1390fde09a4..4ee9fe1a337 100644 --- a/apps/sim/blocks/blocks/google_forms.ts +++ b/apps/sim/blocks/blocks/google_forms.ts @@ -416,7 +416,8 @@ Example for "Add a required multiple choice question about favorite color": outputs: { responses: { type: 'json', - description: 'Array of form responses', + description: + 'Array of form responses [{responseId, createTime, lastSubmittedTime, answers, respondentEmail, totalScore}]', condition: { field: 'operation', value: 'get_responses', @@ -434,7 +435,8 @@ Example for "Add a required multiple choice question about favorite color": }, response: { type: 'json', - description: 'Single form response', + description: + 'Single form response (responseId, createTime, lastSubmittedTime, answers, respondentEmail, totalScore)', condition: { field: 'operation', value: 'get_responses', diff --git a/apps/sim/blocks/blocks/google_groups.test.ts b/apps/sim/blocks/blocks/google_groups.test.ts new file mode 100644 index 00000000000..3a218434b93 --- /dev/null +++ b/apps/sim/blocks/blocks/google_groups.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups' + +/** + * The executor builds tool params as `{ ...inputs, ...transformedParams }`, so an empty dropdown + * value the serializer fills in reaches the tool unless the mapper assigns that key explicitly. + */ +describe('GoogleGroupsBlock update settings', () => { + const mapParams = GoogleGroupsBlock.tools.config?.params as ( + params: Record + ) => Record + + it('maps every unchanged setting to undefined so empty values are never sent', () => { + const mapped = mapParams({ + operation: 'update_settings', + oauthCredential: 'cred-1', + groupEmail: 'team@example.com', + whoCanJoin: '', + whoCanViewMembership: '', + whoCanViewGroup: '', + whoCanPostMessage: '', + allowExternalMembers: '', + customReplyTo: '', + }) + for (const key of [ + 'whoCanJoin', + 'whoCanViewMembership', + 'whoCanViewGroup', + 'whoCanPostMessage', + 'allowExternalMembers', + 'customReplyTo', + ]) { + expect(mapped).toHaveProperty(key, undefined) + } + }) + + it('forwards a chosen setting as a string', () => { + const mapped = mapParams({ + operation: 'update_settings', + oauthCredential: 'cred-1', + groupEmail: 'team@example.com', + whoCanJoin: 'INVITED_CAN_JOIN', + }) + expect(mapped.whoCanJoin).toBe('INVITED_CAN_JOIN') + }) +}) diff --git a/apps/sim/blocks/blocks/google_groups.ts b/apps/sim/blocks/blocks/google_groups.ts index 14ffa3576a1..4b94aa69d7c 100644 --- a/apps/sim/blocks/blocks/google_groups.ts +++ b/apps/sim/blocks/blocks/google_groups.ts @@ -4,6 +4,31 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' +/** Settings the Update Settings operation forwards when set; empty means unchanged. */ +const UPDATE_SETTINGS_FIELDS = [ + 'whoCanJoin', + 'whoCanViewMembership', + 'whoCanViewGroup', + 'whoCanPostMessage', + 'allowExternalMembers', + 'whoCanDiscoverGroup', + 'whoCanContactOwner', + 'whoCanLeaveGroup', + 'whoCanModerateMembers', + 'whoCanModerateContent', + 'messageModerationLevel', + 'spamModerationLevel', + 'replyTo', + 'allowWebPosting', + 'isArchived', + 'archiveOnly', + 'includeInGlobalAddressList', + 'membersCanPostAsTheGroup', + 'enableCollaborativeInbox', + 'customReplyTo', + 'primaryLanguage', +] as const + export const GoogleGroupsBlock: BlockConfig = { type: 'google_groups', name: 'Google Groups', @@ -322,6 +347,326 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, placeholder: 'OWNER,MANAGER,MEMBER', condition: { field: 'operation', value: 'list_members' }, }, + { + id: 'userKey', + title: 'Member Email or ID', + type: 'short-input', + placeholder: 'Only groups this user belongs to (e.g., user@example.com)', + condition: { field: 'operation', value: 'list_groups' }, + }, + { + id: 'sortOrder', + title: 'Sort by Email', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Ascending', id: 'ASCENDING' }, + { label: 'Descending', id: 'DESCENDING' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_groups' }, + mode: 'advanced', + }, + { + id: 'includeDerivedMembership', + title: 'Include Nested Members', + type: 'switch', + condition: { field: 'operation', value: 'list_members' }, + mode: 'advanced', + }, + { + id: 'pageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'Token from a previous response', + condition: { field: 'operation', value: ['list_groups', 'list_members'] }, + mode: 'advanced', + }, + // Update Settings Fields (empty means unchanged) + { + id: 'whoCanJoin', + title: 'Who Can Join', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Anyone Can Join', id: 'ANYONE_CAN_JOIN' }, + { label: 'All In Domain Can Join', id: 'ALL_IN_DOMAIN_CAN_JOIN' }, + { label: 'Invited Can Join', id: 'INVITED_CAN_JOIN' }, + { label: 'Can Request To Join', id: 'CAN_REQUEST_TO_JOIN' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + }, + { + id: 'whoCanViewMembership', + title: 'Who Can View Members', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'All In Domain Can View', id: 'ALL_IN_DOMAIN_CAN_VIEW' }, + { label: 'All Members Can View', id: 'ALL_MEMBERS_CAN_VIEW' }, + { label: 'All Managers Can View', id: 'ALL_MANAGERS_CAN_VIEW' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + }, + { + id: 'whoCanViewGroup', + title: 'Who Can View Messages', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Anyone Can View', id: 'ANYONE_CAN_VIEW' }, + { label: 'All In Domain Can View', id: 'ALL_IN_DOMAIN_CAN_VIEW' }, + { label: 'All Members Can View', id: 'ALL_MEMBERS_CAN_VIEW' }, + { label: 'All Managers Can View', id: 'ALL_MANAGERS_CAN_VIEW' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + }, + { + id: 'whoCanPostMessage', + title: 'Who Can Post', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'None Can Post', id: 'NONE_CAN_POST' }, + { label: 'All Managers Can Post', id: 'ALL_MANAGERS_CAN_POST' }, + { label: 'All Members Can Post', id: 'ALL_MEMBERS_CAN_POST' }, + { label: 'All Owners Can Post', id: 'ALL_OWNERS_CAN_POST' }, + { label: 'All In Domain Can Post', id: 'ALL_IN_DOMAIN_CAN_POST' }, + { label: 'Anyone Can Post', id: 'ANYONE_CAN_POST' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + }, + { + id: 'allowExternalMembers', + title: 'Allow External Members', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + }, + { + id: 'whoCanDiscoverGroup', + title: 'Who Can Discover', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Anyone Can Discover', id: 'ANYONE_CAN_DISCOVER' }, + { label: 'All In Domain Can Discover', id: 'ALL_IN_DOMAIN_CAN_DISCOVER' }, + { label: 'All Members Can Discover', id: 'ALL_MEMBERS_CAN_DISCOVER' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'whoCanContactOwner', + title: 'Who Can Contact Owner', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Anyone Can Contact', id: 'ANYONE_CAN_CONTACT' }, + { label: 'All In Domain Can Contact', id: 'ALL_IN_DOMAIN_CAN_CONTACT' }, + { label: 'All Members Can Contact', id: 'ALL_MEMBERS_CAN_CONTACT' }, + { label: 'All Managers Can Contact', id: 'ALL_MANAGERS_CAN_CONTACT' }, + { label: 'All Owners Can Contact', id: 'ALL_OWNERS_CAN_CONTACT' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'whoCanLeaveGroup', + title: 'Who Can Leave', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'All Members Can Leave', id: 'ALL_MEMBERS_CAN_LEAVE' }, + { label: 'All Managers Can Leave', id: 'ALL_MANAGERS_CAN_LEAVE' }, + { label: 'None Can Leave', id: 'NONE_CAN_LEAVE' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'whoCanModerateMembers', + title: 'Who Can Manage Members', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'All Members', id: 'ALL_MEMBERS' }, + { label: 'Owners And Managers', id: 'OWNERS_AND_MANAGERS' }, + { label: 'Owners Only', id: 'OWNERS_ONLY' }, + { label: 'None', id: 'NONE' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'whoCanModerateContent', + title: 'Who Can Moderate Content', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'All Members', id: 'ALL_MEMBERS' }, + { label: 'Owners And Managers', id: 'OWNERS_AND_MANAGERS' }, + { label: 'Owners Only', id: 'OWNERS_ONLY' }, + { label: 'None', id: 'NONE' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'messageModerationLevel', + title: 'Message Moderation', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Moderate All Messages', id: 'MODERATE_ALL_MESSAGES' }, + { label: 'Moderate Non Members', id: 'MODERATE_NON_MEMBERS' }, + { label: 'Moderate New Members', id: 'MODERATE_NEW_MEMBERS' }, + { label: 'Moderate None', id: 'MODERATE_NONE' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'spamModerationLevel', + title: 'Spam Handling', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Allow', id: 'ALLOW' }, + { label: 'Moderate', id: 'MODERATE' }, + { label: 'Silently Moderate', id: 'SILENTLY_MODERATE' }, + { label: 'Reject', id: 'REJECT' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'replyTo', + title: 'Default Reply To', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Reply To Sender', id: 'REPLY_TO_SENDER' }, + { label: 'Reply To List', id: 'REPLY_TO_LIST' }, + { label: 'Reply To Owner', id: 'REPLY_TO_OWNER' }, + { label: 'Reply To Custom', id: 'REPLY_TO_CUSTOM' }, + { label: 'Reply To Ignore', id: 'REPLY_TO_IGNORE' }, + { label: 'Reply To Managers', id: 'REPLY_TO_MANAGERS' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'allowWebPosting', + title: 'Allow Web Posting', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'isArchived', + title: 'Archive Messages', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'archiveOnly', + title: 'Archive Only (Inactive)', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'includeInGlobalAddressList', + title: 'Include in Global Address List', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'membersCanPostAsTheGroup', + title: 'Members Can Post as Group', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'enableCollaborativeInbox', + title: 'Collaborative Inbox', + type: 'dropdown', + options: [ + { label: 'No change', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'customReplyTo', + title: 'Custom Reply-To Email', + type: 'short-input', + placeholder: 'reply@example.com', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, + { + id: 'primaryLanguage', + title: 'Primary Language', + type: 'short-input', + placeholder: 'en-US', + condition: { field: 'operation', value: 'update_settings' }, + mode: 'advanced', + }, ], tools: { access: [ @@ -392,6 +737,10 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, domain: rest.domain, query: rest.query, maxResults: rest.maxResults ? Number(rest.maxResults) : undefined, + pageToken: rest.pageToken || undefined, + userKey: rest.userKey || undefined, + orderBy: rest.sortOrder ? 'email' : undefined, + sortOrder: rest.sortOrder || undefined, } case 'get_group': case 'delete_group': @@ -420,6 +769,11 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, groupKey: rest.groupKey, maxResults: rest.maxResults ? Number(rest.maxResults) : undefined, roles: rest.roles, + pageToken: rest.pageToken || undefined, + includeDerivedMembership: + rest.includeDerivedMembership === true || rest.includeDerivedMembership === 'true' + ? true + : undefined, } case 'get_member': case 'remove_member': @@ -474,6 +828,14 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, return { oauthCredential, groupEmail: rest.groupEmail, + ...Object.fromEntries( + UPDATE_SETTINGS_FIELDS.map((key) => [ + key, + rest[key] === undefined || rest[key] === null || rest[key] === '' + ? undefined + : String(rest[key]), + ]) + ), } default: return { oauthCredential, ...rest } @@ -500,6 +862,79 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, roles: { type: 'string', description: 'Filter by roles for list members' }, alias: { type: 'string', description: 'Alias email address' }, groupEmail: { type: 'string', description: 'Group email address for settings operations' }, + userKey: { type: 'string', description: 'Only list groups this member belongs to' }, + sortOrder: { type: 'string', description: 'Sort groups by email (ASCENDING or DESCENDING)' }, + includeDerivedMembership: { type: 'boolean', description: 'Include indirect members' }, + pageToken: { type: 'string', description: 'Pagination token' }, + whoCanJoin: { type: 'string', description: 'Group setting whoCanJoin (update_settings)' }, + whoCanViewMembership: { + type: 'string', + description: 'Group setting whoCanViewMembership (update_settings)', + }, + whoCanViewGroup: { + type: 'string', + description: 'Group setting whoCanViewGroup (update_settings)', + }, + whoCanPostMessage: { + type: 'string', + description: 'Group setting whoCanPostMessage (update_settings)', + }, + allowExternalMembers: { + type: 'string', + description: 'Group setting allowExternalMembers (update_settings)', + }, + whoCanDiscoverGroup: { + type: 'string', + description: 'Group setting whoCanDiscoverGroup (update_settings)', + }, + whoCanContactOwner: { + type: 'string', + description: 'Group setting whoCanContactOwner (update_settings)', + }, + whoCanLeaveGroup: { + type: 'string', + description: 'Group setting whoCanLeaveGroup (update_settings)', + }, + whoCanModerateMembers: { + type: 'string', + description: 'Group setting whoCanModerateMembers (update_settings)', + }, + whoCanModerateContent: { + type: 'string', + description: 'Group setting whoCanModerateContent (update_settings)', + }, + messageModerationLevel: { + type: 'string', + description: 'Group setting messageModerationLevel (update_settings)', + }, + spamModerationLevel: { + type: 'string', + description: 'Group setting spamModerationLevel (update_settings)', + }, + replyTo: { type: 'string', description: 'Group setting replyTo (update_settings)' }, + allowWebPosting: { + type: 'string', + description: 'Group setting allowWebPosting (update_settings)', + }, + isArchived: { type: 'string', description: 'Group setting isArchived (update_settings)' }, + archiveOnly: { type: 'string', description: 'Group setting archiveOnly (update_settings)' }, + includeInGlobalAddressList: { + type: 'string', + description: 'Group setting includeInGlobalAddressList (update_settings)', + }, + membersCanPostAsTheGroup: { + type: 'string', + description: 'Group setting membersCanPostAsTheGroup (update_settings)', + }, + enableCollaborativeInbox: { + type: 'string', + description: 'Group setting enableCollaborativeInbox (update_settings)', + }, + customReplyTo: { type: 'string', description: 'Group setting customReplyTo (update_settings)' }, + primaryLanguage: { + type: 'string', + description: 'Group setting primaryLanguage (update_settings)', + }, }, outputs: { groups: { type: 'json', description: 'Array of group objects (for list_groups)' }, @@ -510,7 +945,11 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, message: { type: 'string', description: 'Success message (for delete/remove operations)' }, nextPageToken: { type: 'string', description: 'Token for fetching next page of results' }, aliases: { type: 'json', description: 'Array of alias objects (for list_aliases)' }, - settings: { type: 'json', description: 'Group settings object (for get/update_settings)' }, + settings: { + type: 'json', + description: + 'Group settings object for get/update_settings (email, name, description, whoCanJoin, whoCanViewMembership, whoCanViewGroup, whoCanPostMessage, allowExternalMembers, messageModerationLevel, replyTo, ..., defaultSender)', + }, deleted: { type: 'boolean', description: 'Deletion result (for remove_alias)' }, }, } diff --git a/apps/sim/blocks/blocks/google_meet.ts b/apps/sim/blocks/blocks/google_meet.ts index 1037e01eb50..d97e90b2342 100644 --- a/apps/sim/blocks/blocks/google_meet.ts +++ b/apps/sim/blocks/blocks/google_meet.ts @@ -196,13 +196,26 @@ export const GoogleMeetBlock: BlockConfig = { accessType: { type: 'string', description: 'Access type' }, entryPointAccess: { type: 'string', description: 'Entry point access' }, activeConference: { type: 'string', description: 'Active conference record' }, + moderation: { type: 'string', description: 'Host moderation (ON or OFF)' }, + attendanceReportGenerationType: { + type: 'string', + description: 'Attendance report setting (GENERATE_REPORT or DO_NOT_GENERATE)', + }, + artifactConfig: { + type: 'json', + description: 'Auto recording, transcription, and smart notes settings (ON or OFF each)', + }, ended: { type: 'boolean', description: 'Whether conference was ended' }, conferenceRecords: { type: 'json', description: 'List of conference records' }, startTime: { type: 'string', description: 'Conference start time' }, endTime: { type: 'string', description: 'Conference end time' }, expireTime: { type: 'string', description: 'Record expiration time' }, space: { type: 'string', description: 'Associated space name' }, - participants: { type: 'json', description: 'List of participants' }, + participants: { + type: 'json', + description: + 'List of participants [{ name, earliestStartTime, latestEndTime, displayName, userType, user }]', + }, nextPageToken: { type: 'string', description: 'Next page token' }, totalSize: { type: 'number', description: 'Total participant count' }, }, diff --git a/apps/sim/blocks/blocks/google_sheets.ts b/apps/sim/blocks/blocks/google_sheets.ts index 0fcd4a73133..5563dbae06e 100644 --- a/apps/sim/blocks/blocks/google_sheets.ts +++ b/apps/sim/blocks/blocks/google_sheets.ts @@ -551,6 +551,44 @@ Return ONLY the range string - no sheet name, no explanations, no quotes.`, condition: { field: 'operation', value: 'read' }, mode: 'advanced', }, + { + id: 'valueRenderOption', + title: 'Value Render Option', + type: 'dropdown', + options: [ + { label: 'Formatted (as displayed)', id: 'FORMATTED_VALUE' }, + { label: 'Unformatted (raw values)', id: 'UNFORMATTED_VALUE' }, + { label: 'Formulas', id: 'FORMULA' }, + ], + condition: { field: 'operation', value: ['read', 'batch_get'] }, + mode: 'advanced', + }, + { + id: 'dateTimeRenderOption', + title: 'Date/Time Render Option', + type: 'dropdown', + options: [ + { label: 'Serial Number', id: 'SERIAL_NUMBER' }, + { label: 'Formatted String', id: 'FORMATTED_STRING' }, + ], + condition: { + field: 'operation', + value: ['read', 'batch_get'], + and: { field: 'valueRenderOption', value: ['UNFORMATTED_VALUE', 'FORMULA'] }, + }, + mode: 'advanced', + }, + { + id: 'majorDimension', + title: 'Major Dimension', + type: 'dropdown', + options: [ + { label: 'Rows', id: 'ROWS' }, + { label: 'Columns', id: 'COLUMNS' }, + ], + condition: { field: 'operation', value: 'batch_get' }, + mode: 'advanced', + }, { id: 'values', title: 'Values', @@ -586,6 +624,23 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, ], condition: { field: 'operation', value: ['write', 'update', 'batch_update'] }, }, + { + id: 'appendValueInputOption', + title: 'Value Input Option', + type: 'dropdown', + options: [ + { label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' }, + { label: "Raw (Don't parse formulas)", id: 'RAW' }, + ], + condition: { field: 'operation', value: 'append' }, + }, + { + id: 'includeValuesInResponse', + title: 'Return Updated Values', + type: 'switch', + condition: { field: 'operation', value: ['write', 'update', 'append', 'batch_update'] }, + mode: 'advanced', + }, { id: 'values', title: 'Values', @@ -891,6 +946,8 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, filterColumn, filterValue, filterMatchType, + majorDimension, + appendValueInputOption, ...rest } = params @@ -926,6 +983,11 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, spreadsheetId: effectiveSpreadsheetId, ranges: parsedRanges, oauthCredential, + ...(majorDimension ? { majorDimension } : {}), + ...(rest.valueRenderOption ? { valueRenderOption: rest.valueRenderOption } : {}), + ...(rest.dateTimeRenderOption + ? { dateTimeRenderOption: rest.dateTimeRenderOption } + : {}), } } @@ -1016,6 +1078,10 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, ? { filterValue: filterValue as string } : {}), ...(filterMatchType ? { filterMatchType: filterMatchType as string } : {}), + ...(operation === 'append' + ? { valueInputOption: appendValueInputOption || undefined } + : {}), + majorDimension: undefined, } }, }, @@ -1028,7 +1094,24 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' }, values: { type: 'string', description: 'Cell values data' }, valueInputOption: { type: 'string', description: 'Value input option' }, + appendValueInputOption: { type: 'string', description: 'Value input option for Append' }, insertDataOption: { type: 'string', description: 'Data insertion option' }, + includeValuesInResponse: { + type: 'boolean', + description: 'Return the values of the updated cells in updatedData', + }, + valueRenderOption: { + type: 'string', + description: 'How read values are rendered: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA', + }, + dateTimeRenderOption: { + type: 'string', + description: 'How read dates are rendered: SERIAL_NUMBER or FORMATTED_STRING', + }, + majorDimension: { + type: 'string', + description: 'Major dimension of batch read values: ROWS or COLUMNS', + }, title: { type: 'string', description: 'Title for new spreadsheet' }, sheetTitles: { type: 'string', description: 'Comma-separated sheet names for new spreadsheet' }, ranges: { type: 'string', description: 'JSON array of ranges for batch operations' }, @@ -1083,6 +1166,17 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, 'Filter summary (present only when a filter was requested): applied, column, matchType, columnFound, matchedRows, totalRows', condition: { field: 'operation', value: 'read' }, }, + majorDimension: { + type: 'string', + description: 'Major dimension of the returned values (ROWS or COLUMNS)', + condition: { field: 'operation', value: 'read' }, + }, + updatedData: { + type: 'json', + description: + 'Values of the updated cells (range, majorDimension, values), present only when Return Updated Values is on', + condition: { field: 'operation', value: ['write', 'update', 'append'] }, + }, updatedRange: { type: 'string', description: 'Updated range', diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts index 6df71191c55..f58dcaf077b 100644 --- a/apps/sim/blocks/blocks/google_slides.ts +++ b/apps/sim/blocks/blocks/google_slides.ts @@ -3466,6 +3466,34 @@ Return ONLY the text content - no explanations, no markdown formatting markers, pageType: { type: 'string', description: 'Page type (SLIDE, MASTER, etc.)' }, pageElements: { type: 'json', description: 'Page elements array' }, slideProperties: { type: 'json', description: 'Slide-specific properties' }, + slideContents: { + type: 'json', + description: + 'Plain-text view of each slide from Read: [{objectId, index, text, speakerNotes, isSkipped}]', + }, + revisionId: { + type: 'string', + description: 'Presentation revision ID from Get Page (Read returns it in metadata)', + }, + pageProperties: { + type: 'json', + description: 'Page properties from Get Page (pageBackgroundFill, colorScheme)', + }, + layoutProperties: { + type: 'json', + description: + 'Layout properties from Get Page, for layout pages (masterObjectId, name, displayName)', + }, + notesProperties: { + type: 'json', + description: 'Notes properties from Get Page, for notes pages (speakerNotesObjectId)', + }, + masterProperties: { + type: 'json', + description: 'Master properties from Get Page, for master pages (displayName)', + }, + pageText: { type: 'string', description: 'Text on the page from Get Page' }, + speakerNotes: { type: 'string', description: 'Speaker notes of the slide from Get Page' }, deleted: { type: 'boolean', description: 'Whether object was deleted' }, duplicatedObjectId: { type: 'string', description: 'Object ID of the duplicate' }, moved: { type: 'boolean', description: 'Whether slides were moved' }, diff --git a/apps/sim/blocks/blocks/google_tasks.test.ts b/apps/sim/blocks/blocks/google_tasks.test.ts new file mode 100644 index 00000000000..1f073772be0 --- /dev/null +++ b/apps/sim/blocks/blocks/google_tasks.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { GoogleTasksBlock } from '@/blocks/blocks/google_tasks' + +/** + * The executor builds tool params as `{ ...inputs, ...transformedParams }`, so an empty dropdown + * value the serializer fills in reaches the tool unless the mapper assigns that key explicitly. + */ +describe('GoogleTasksBlock list filters', () => { + const mapParams = GoogleTasksBlock.tools.config?.params as ( + params: Record + ) => Record + + it('maps unset tri-state filters to undefined', () => { + const mapped = mapParams({ + operation: 'list', + oauthCredential: 'cred-1', + showHidden: '', + showDeleted: '', + showAssigned: '', + }) + for (const key of ['showHidden', 'showDeleted', 'showAssigned']) { + expect(mapped).toHaveProperty(key, undefined) + } + }) + + it('converts chosen filters to booleans', () => { + const mapped = mapParams({ operation: 'list', oauthCredential: 'cred-1', showHidden: 'true' }) + expect(mapped.showHidden).toBe(true) + }) +}) diff --git a/apps/sim/blocks/blocks/google_tasks.ts b/apps/sim/blocks/blocks/google_tasks.ts index 3d1d6ba3ca8..39899bd5200 100644 --- a/apps/sim/blocks/blocks/google_tasks.ts +++ b/apps/sim/blocks/blocks/google_tasks.ts @@ -2,7 +2,7 @@ import { GoogleTasksIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' +import { parseOptionalBooleanInput, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' /** * Canonical basic/advanced pair for the task list, shared by the card @@ -159,6 +159,23 @@ Return ONLY the timestamp - no explanations, no extra text.`, ], }, + { + id: 'parent', + title: 'Parent Task ID', + type: 'short-input', + placeholder: 'Create as a subtask of this task', + condition: { field: 'operation', value: 'create' }, + mode: 'advanced', + }, + { + id: 'previous', + title: 'Previous Sibling Task ID', + type: 'short-input', + placeholder: 'Place after this task (first if empty)', + condition: { field: 'operation', value: 'create' }, + mode: 'advanced', + }, + // Get/Update/Delete Task Fields - Task ID { id: 'taskId', @@ -231,6 +248,133 @@ Return ONLY the timestamp - no explanations, no extra text.`, { label: 'No', id: 'false' }, ], }, + { + id: 'showHidden', + title: 'Show Hidden', + type: 'dropdown', + condition: { field: 'operation', value: 'list' }, + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + }, + { + id: 'showDeleted', + title: 'Show Deleted', + type: 'dropdown', + condition: { field: 'operation', value: 'list' }, + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + }, + { + id: 'showAssigned', + title: 'Show Assigned', + type: 'dropdown', + condition: { field: 'operation', value: 'list' }, + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + }, + { + id: 'dueMin', + title: 'Due After', + type: 'short-input', + placeholder: '2025-06-01T00:00:00.000Z', + condition: { field: 'operation', value: 'list' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone). + +Return ONLY the timestamp - no explanations, no extra text.`, + generationType: 'timestamp', + }, + }, + { + id: 'dueMax', + title: 'Due Before', + type: 'short-input', + placeholder: '2025-06-30T00:00:00.000Z', + condition: { field: 'operation', value: 'list' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone). + +Return ONLY the timestamp - no explanations, no extra text.`, + generationType: 'timestamp', + }, + }, + { + id: 'completedMin', + title: 'Completed After', + type: 'short-input', + placeholder: '2025-06-01T00:00:00.000Z', + condition: { field: 'operation', value: 'list' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone). + +Return ONLY the timestamp - no explanations, no extra text.`, + generationType: 'timestamp', + }, + }, + { + id: 'completedMax', + title: 'Completed Before', + type: 'short-input', + placeholder: '2025-06-30T00:00:00.000Z', + condition: { field: 'operation', value: 'list' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone). + +Return ONLY the timestamp - no explanations, no extra text.`, + generationType: 'timestamp', + }, + }, + { + id: 'updatedMin', + title: 'Updated After', + type: 'short-input', + placeholder: '2025-06-01T00:00:00.000Z', + condition: { field: 'operation', value: 'list' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone). + +Return ONLY the timestamp - no explanations, no extra text.`, + generationType: 'timestamp', + }, + }, + { + id: 'pageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'Token from a previous response', + condition: { field: 'operation', value: ['list', 'list_task_lists'] }, + mode: 'advanced', + }, ], tools: { @@ -276,6 +420,10 @@ Return ONLY the timestamp - no explanations, no extra text.`, processedParams.showCompleted = showCompleted === 'true' } + for (const key of ['showHidden', 'showDeleted', 'showAssigned'] as const) { + processedParams[key] = parseOptionalBooleanInput(processedParams[key]) + } + return { oauthCredential, ...processedParams, @@ -295,6 +443,20 @@ Return ONLY the timestamp - no explanations, no extra text.`, taskId: { type: 'string', description: 'Task identifier' }, maxResults: { type: 'string', description: 'Maximum number of results' }, showCompleted: { type: 'string', description: 'Whether to show completed tasks' }, + parent: { type: 'string', description: 'Parent task ID for a subtask' }, + previous: { type: 'string', description: 'Previous sibling task ID' }, + showHidden: { type: 'string', description: 'Whether to show hidden tasks' }, + showDeleted: { type: 'string', description: 'Whether to show deleted tasks' }, + showAssigned: { + type: 'string', + description: 'Whether to show tasks assigned from Docs or Chat', + }, + dueMin: { type: 'string', description: 'Lower bound for due date' }, + dueMax: { type: 'string', description: 'Upper bound for due date' }, + completedMin: { type: 'string', description: 'Lower bound for completion date' }, + completedMax: { type: 'string', description: 'Upper bound for completion date' }, + updatedMin: { type: 'string', description: 'Lower bound for last modification time' }, + pageToken: { type: 'string', description: 'Pagination token' }, }, outputs: { @@ -310,7 +472,18 @@ Return ONLY the timestamp - no explanations, no extra text.`, position: { type: 'string', description: 'Position among sibling tasks' }, completed: { type: 'string', description: 'Completion date' }, deleted: { type: 'boolean', description: 'Whether the task is deleted' }, - tasks: { type: 'json', description: 'Array of tasks (list operation)' }, + hidden: { type: 'boolean', description: 'Whether the task is hidden' }, + links: { type: 'json', description: 'Links attached to the task [{type, description, link}]' }, + assignmentInfo: { + type: 'json', + description: + 'Source of a task assigned from Docs or Chat (linkToTask, surfaceType, driveResourceInfo, spaceInfo)', + }, + tasks: { + type: 'json', + description: + 'Array of tasks (list operation): [{id, title, notes, status, due, completed, updated, selfLink, webViewLink, parent, position, hidden, deleted, links, assignmentInfo}]', + }, taskLists: { type: 'json', description: 'Array of task lists (list_task_lists operation)' }, taskId: { type: 'string', description: 'Deleted task ID (delete operation)' }, nextPageToken: { type: 'string', description: 'Token for next page of results' }, diff --git a/apps/sim/blocks/blocks/google_vault.ts b/apps/sim/blocks/blocks/google_vault.ts index c5eb6608df2..ea4c05148fe 100644 --- a/apps/sim/blocks/blocks/google_vault.ts +++ b/apps/sim/blocks/blocks/google_vault.ts @@ -264,6 +264,32 @@ export const GoogleVaultBlock: BlockConfig = { placeholder: 'Enter Matter ID (optional to fetch a specific matter)', condition: { field: 'operation', value: 'list_matters' }, }, + { + id: 'matterView', + title: 'Detail Level', + type: 'dropdown', + options: [ + { label: 'Basic', id: 'BASIC' }, + { label: 'Full (include collaborators)', id: 'FULL' }, + ], + value: () => 'BASIC', + condition: { field: 'operation', value: 'list_matters' }, + mode: 'advanced', + }, + { + id: 'matterState', + title: 'Matter State', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Open', id: 'OPEN' }, + { label: 'Closed', id: 'CLOSED' }, + { label: 'Deleted', id: 'DELETED' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_matters' }, + mode: 'advanced', + }, // Download Export File inputs { id: 'bucketName', @@ -561,6 +587,46 @@ Return ONLY the search query - no explanations, no quotes, no extra text.`, placeholder: 'Describe what content to search for...', }, }, + { + id: 'exportIncludeSharedDrives', + title: 'Include Shared Drives', + type: 'switch', + condition: { + field: 'operation', + value: ['create_matters_export', 'create_saved_query'], + and: { field: 'corpus', value: 'DRIVE' }, + }, + }, + { + id: 'exportFormat', + title: 'Export Format', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'MBOX', id: 'MBOX' }, + { label: 'PST', id: 'PST' }, + ], + value: () => '', + condition: { + field: 'operation', + value: 'create_matters_export', + and: { field: 'corpus', value: 'DRIVE', not: true }, + }, + mode: 'advanced', + }, + { + id: 'dataScope', + title: 'Data Scope', + type: 'dropdown', + options: [ + { label: 'All data', id: 'ALL_DATA' }, + { label: 'Held data only', id: 'HELD_DATA' }, + { label: 'Unprocessed data (Mail and Groups)', id: 'UNPROCESSED_DATA' }, + ], + value: () => 'ALL_DATA', + condition: { field: 'operation', value: ['create_matters_export', 'create_saved_query'] }, + mode: 'advanced', + }, // Drive-specific option for holds { id: 'includeSharedDrives', @@ -625,6 +691,7 @@ Return ONLY the search query - no explanations, no quotes, no extra text.`, field: 'operation', value: ['list_matters_export', 'list_matters_holds', 'list_matters', 'list_saved_queries'], }, + mode: 'advanced', }, { id: 'pageToken', @@ -635,6 +702,7 @@ Return ONLY the search query - no explanations, no quotes, no extra text.`, field: 'operation', value: ['list_matters_export', 'list_matters_holds', 'list_matters', 'list_saved_queries'], }, + mode: 'advanced', }, { @@ -854,8 +922,15 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, listExportId, listHoldId, listSavedQueryId, + matterView, + matterState, + exportIncludeSharedDrives, + exportFormat, + dataScope, ...rest } = params + const isQueryOperation = + params.operation === 'create_matters_export' || params.operation === 'create_saved_query' return { ...rest, oauthCredential, @@ -879,6 +954,14 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, ...(listExportId && { exportId: listExportId }), ...(listHoldId && { holdId: listHoldId }), ...(listSavedQueryId && { savedQueryId: listSavedQueryId }), + ...(params.operation === 'list_matters' && matterView && { view: matterView }), + ...(params.operation === 'list_matters' && matterState && { state: matterState }), + ...(isQueryOperation && { + includeSharedDrives: + exportIncludeSharedDrives === true || exportIncludeSharedDrives === 'true', + ...(dataScope && { dataScope }), + }), + ...(params.operation === 'create_matters_export' && exportFormat && { exportFormat }), } }, }, @@ -978,19 +1061,43 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, type: 'string', description: 'Specific saved query ID to fetch (list_saved_queries)', }, + matterView: { + type: 'string', + description: 'Matter detail level: BASIC or FULL (list_matters)', + }, + matterState: { + type: 'string', + description: 'Only list matters in this state: OPEN, CLOSED, or DELETED (list_matters)', + }, + exportIncludeSharedDrives: { + type: 'boolean', + description: 'Include shared drive files (DRIVE create_matters_export, create_saved_query)', + }, + exportFormat: { + type: 'string', + description: 'Export file format: MBOX or PST (create_matters_export, non-DRIVE corpus)', + }, + dataScope: { + type: 'string', + description: + 'Data to search: ALL_DATA, HELD_DATA, or UNPROCESSED_DATA (create_matters_export, create_saved_query)', + }, }, outputs: { matters: { type: 'json', - description: 'Array of matter objects (for list_matters without matterId)', + description: + 'Array of matter objects (matterId, name, description, state; matterPermissions with Full detail) (for list_matters without matterId)', }, exports: { type: 'json', - description: 'Array of export objects (for list_matters_export without exportId)', + description: + 'Array of export objects (id, name, status, createTime, stats, cloudStorageSink.files with bucketName/objectName/size) (for list_matters_export without exportId)', }, holds: { type: 'json', - description: 'Array of hold objects (for list_matters_holds without holdId)', + description: + 'Array of hold objects (holdId, name, corpus, updateTime, query, accounts or orgUnit) (for list_matters_holds without holdId)', }, matter: { type: 'json', diff --git a/apps/sim/blocks/blocks/iam.ts b/apps/sim/blocks/blocks/iam.ts index cb5015562d5..ce502858d6a 100644 --- a/apps/sim/blocks/blocks/iam.ts +++ b/apps/sim/blocks/blocks/iam.ts @@ -1,8 +1,42 @@ +import { isRecordLike } from '@sim/utils/object' import { IAMIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { withSavedCredential } from '@/blocks/utils' +/** + * Converts the Tags table, a `[{ key, value }]` list (or its JSON string), a plain + * object, or a JSON object string into the JSON object string the tools accept. + */ +function serializeTags(tags: unknown): string | undefined { + if (typeof tags === 'string') { + const trimmed = tags.trim() + if (!trimmed) return undefined + if (!trimmed.startsWith('[')) return trimmed + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + return trimmed + } + const serialized = serializeTags(parsed) + if (serialized === undefined && Array.isArray(parsed) && parsed.length > 0) { + throw new Error('Tags must be a list of {"key", "value"} entries or a JSON object') + } + return serialized + } + if (isRecordLike(tags)) return Object.keys(tags).length > 0 ? JSON.stringify(tags) : undefined + if (!Array.isArray(tags)) return undefined + const obj: Record = {} + for (const row of tags) { + const entry = isRecordLike(row) && isRecordLike(row.cells) ? row.cells : row + const key = isRecordLike(entry) ? entry.key : undefined + const value = isRecordLike(entry) ? entry.value : undefined + if (typeof key === 'string' && key && value !== undefined) obj[key] = String(value ?? '') + } + return Object.keys(obj).length > 0 ? JSON.stringify(obj) : undefined +} + /** * Raised when `contextEntries` is not a JSON array of context entries. * @@ -347,6 +381,24 @@ export const IAMBlock: BlockConfig = { required: false, mode: 'advanced', }, + { + id: 'permissionsBoundary', + title: 'Permissions Boundary ARN', + type: 'short-input', + placeholder: 'arn:aws:iam::123456789012:policy/MyBoundary', + condition: { field: 'operation', value: ['create_user', 'create_role'] }, + required: false, + mode: 'advanced', + }, + { + id: 'tags', + title: 'Tags', + type: 'table', + columns: ['key', 'value'], + condition: { field: 'operation', value: ['create_user', 'create_role'] }, + required: false, + mode: 'advanced', + }, { id: 'scope', title: 'Policy Scope', @@ -374,6 +426,20 @@ export const IAMBlock: BlockConfig = { required: false, mode: 'advanced', }, + { + id: 'policyUsageFilter', + title: 'Policy Usage', + type: 'dropdown', + options: [ + { label: 'All', id: 'all' }, + { label: 'Permissions Policies', id: 'PermissionsPolicy' }, + { label: 'Permissions Boundaries', id: 'PermissionsBoundary' }, + ], + value: () => 'all', + condition: { field: 'operation', value: 'list_policies' }, + required: false, + mode: 'advanced', + }, { id: 'policySourceArn', title: 'Principal ARN', @@ -602,15 +668,18 @@ export const IAMBlock: BlockConfig = { case 'delete_user': result.userName = rest.userName break - case 'create_user': + case 'create_user': { result.userName = rest.userName if (rest.path) result.path = rest.path + result.permissionsBoundary = rest.permissionsBoundary || undefined + result.tags = serializeTags(rest.tags) break + } case 'get_role': case 'delete_role': result.roleName = rest.roleName break - case 'create_role': + case 'create_role': { result.roleName = rest.roleName result.assumeRolePolicyDocument = rest.assumeRolePolicyDocument if (rest.description) result.description = rest.description @@ -619,7 +688,10 @@ export const IAMBlock: BlockConfig = { const parsed = Number.parseInt(String(maxSessionDuration), 10) if (!Number.isNaN(parsed)) result.maxSessionDuration = parsed } + result.permissionsBoundary = rest.permissionsBoundary || undefined + result.tags = serializeTags(rest.tags) break + } case 'attach_user_policy': case 'detach_user_policy': result.userName = rest.userName @@ -633,6 +705,11 @@ export const IAMBlock: BlockConfig = { case 'list_policies': if (rest.scope) result.scope = rest.scope if (onlyAttached === 'true' || onlyAttached === true) result.onlyAttached = true + result.policyUsageFilter = + rest.policyUsageFilter === 'PermissionsPolicy' || + rest.policyUsageFilter === 'PermissionsBoundary' + ? rest.policyUsageFilter + : undefined if (rest.pathPrefix) result.pathPrefix = rest.pathPrefix if (maxItems) { const parsed = Number.parseInt(String(maxItems), 10) @@ -734,6 +811,15 @@ export const IAMBlock: BlockConfig = { maxSessionDuration: { type: 'number', description: 'Max session duration in seconds' }, scope: { type: 'string', description: 'Policy scope filter (All, AWS, Local)' }, onlyAttached: { type: 'string', description: 'Only return attached policies' }, + policyUsageFilter: { + type: 'string', + description: 'Policy usage filter (PermissionsPolicy, PermissionsBoundary)', + }, + permissionsBoundary: { + type: 'string', + description: 'Managed policy ARN to set as the permissions boundary on a new user or role', + }, + tags: { type: 'json', description: 'Tag key/value pairs for a new user or role' }, pathPrefix: { type: 'string', description: 'Path prefix filter' }, maxItems: { type: 'number', description: 'Maximum number of items to return' }, marker: { type: 'string', description: 'Pagination marker' }, @@ -766,7 +852,7 @@ export const IAMBlock: BlockConfig = { policies: { type: 'json', description: - 'List of IAM policies (policyName, policyId, arn, path, attachmentCount, isAttachable, createDate, updateDate)', + 'List of IAM policies (policyName, policyId, arn, path, attachmentCount, isAttachable, createDate, updateDate, defaultVersionId, permissionsBoundaryUsageCount)', }, groups: { type: 'json', diff --git a/apps/sim/blocks/blocks/identity_center.ts b/apps/sim/blocks/blocks/identity_center.ts index bf9244c3b2b..a9b885903ac 100644 --- a/apps/sim/blocks/blocks/identity_center.ts +++ b/apps/sim/blocks/blocks/identity_center.ts @@ -296,6 +296,15 @@ export const IdentityCenterBlock: BlockConfig = { ], }, }, + { + id: 'filterAccountId', + title: 'Filter by AWS Account ID', + type: 'short-input', + placeholder: '123456789012', + condition: { field: 'operation', value: 'list_account_assignments' }, + required: false, + mode: 'advanced', + }, { id: 'requestId', title: 'Request ID', @@ -496,6 +505,7 @@ export const IdentityCenterBlock: BlockConfig = { result.instanceArn = rest.instanceArn result.principalId = rest.principalId result.principalType = rest.principalType + result.accountId = rest.filterAccountId || undefined if (maxResults) { const parsed = Number.parseInt(String(maxResults), 10) if (!Number.isNaN(parsed)) result.maxResults = parsed @@ -534,6 +544,10 @@ export const IdentityCenterBlock: BlockConfig = { userId: { type: 'string', description: 'Identity Store user ID' }, groupId: { type: 'string', description: 'Identity Store group ID' }, requestId: { type: 'string', description: 'Assignment creation/deletion request ID' }, + filterAccountId: { + type: 'string', + description: 'Only list assignments for this AWS account ID (list_account_assignments)', + }, maxResults: { type: 'number', description: 'Maximum number of results to return (1-100; List Accounts allows at most 20)', @@ -570,6 +584,8 @@ export const IdentityCenterBlock: BlockConfig = { email: { type: 'string', description: 'Email address of the user' }, userStatus: { type: 'string', description: 'User account status (ENABLED or DISABLED)' }, title: { type: 'string', description: 'Job title of the user' }, + givenName: { type: 'string', description: 'Given (first) name of the user' }, + familyName: { type: 'string', description: 'Family (last) name of the user' }, externalIds: { type: 'json', description: 'External identity provider IDs on the user or group (issuer, id)', diff --git a/apps/sim/blocks/blocks/intune.ts b/apps/sim/blocks/blocks/intune.ts index c8bdca0cd48..fb1b0614601 100644 --- a/apps/sim/blocks/blocks/intune.ts +++ b/apps/sim/blocks/blocks/intune.ts @@ -246,13 +246,13 @@ export const IntuneBlock: BlockConfig = { devices: { type: 'json', description: - 'Managed devices (id, deviceName, operatingSystem, osVersion, complianceState, userPrincipalName, lastSyncDateTime, isEncrypted, serialNumber, manufacturer, model)', + 'Managed devices (id, deviceName, operatingSystem, osVersion, complianceState, userPrincipalName, lastSyncDateTime, isEncrypted, serialNumber, manufacturer, model, managementAgent, deviceEnrollmentType, jailBroken, isSupervised, complianceGracePeriodExpirationDateTime, exchangeAccessState, imei, phoneNumber, wiFiMacAddress, deviceActionResults)', condition: { field: 'operation', value: ['intune_list_devices', 'intune_list_app_devices'] }, }, device: { type: 'json', description: - 'Managed device (id, deviceName, operatingSystem, osVersion, complianceState, managementState, userPrincipalName, lastSyncDateTime, isEncrypted, serialNumber, storage capacity)', + 'Managed device (id, deviceName, operatingSystem, osVersion, complianceState, userPrincipalName, lastSyncDateTime, isEncrypted, serialNumber, storage capacity, managementAgent, deviceEnrollmentType, deviceRegistrationState, jailBroken, isSupervised, complianceGracePeriodExpirationDateTime, partnerReportedThreatState, exchangeAccessState, imei, phoneNumber, wiFiMacAddress, deviceActionResults with actionName and actionState of remote actions)', condition: { field: 'operation', value: ['intune_get_device'] }, }, apps: { @@ -270,25 +270,25 @@ export const IntuneBlock: BlockConfig = { policies: { type: 'json', description: - 'Compliance policy metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version)', + 'Compliance policy metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version, odataType naming the platform policy type)', condition: { field: 'operation', value: ['intune_list_compliance_policies'] }, }, policy: { type: 'json', description: - 'Common compliance policy metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version); excludes platform-specific rule settings', + 'Common compliance policy metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version, odataType naming the platform policy type); excludes platform-specific rule settings', condition: { field: 'operation', value: ['intune_get_compliance_policy'] }, }, configurations: { type: 'json', description: - 'Device configuration metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version)', + 'Device configuration metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version, odataType naming the platform configuration type)', condition: { field: 'operation', value: ['intune_list_device_configurations'] }, }, configuration: { type: 'json', description: - 'Common device configuration metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version); excludes platform-specific settings', + 'Common device configuration metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version, odataType naming the platform configuration type); excludes platform-specific settings', condition: { field: 'operation', value: ['intune_get_device_configuration'] }, }, deviceStatuses: { diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index 081576bcb62..454636cd4e4 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -537,6 +537,37 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n condition: { field: 'operation', value: ['write', 'update'] }, mode: 'advanced', }, + { + id: 'customFields', + title: 'Additional Fields', + type: 'code', + language: 'json', + placeholder: '{"customfield_10016": 5, "customfield_10050": {"value": "Option A"}}', + condition: { field: 'operation', value: ['write', 'update'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a JSON object mapping Jira field IDs to values for an issue create or edit request, based on the user's description. +Use Jira REST API v3 value shapes: +- Number or text custom fields: "customfield_10016": 5 +- Select lists: "customfield_10050": {"value": "Option A"} +- Multi-select: "customfield_10051": [{"value": "A"}, {"value": "B"}] +- User pickers: "customfield_10060": {"accountId": "5b10a2844c20165700ede21g"} +- Sprint: "customfield_10020": 42 + +Return ONLY the JSON object - no explanations or markdown formatting.`, + placeholder: 'Describe the fields to set (e.g., "story points 5, team Platform")...', + generationType: 'json-object', + }, + }, + { + id: 'updateParentIssue', + title: 'New Parent Issue Key', + type: 'short-input', + placeholder: 'Parent issue key or epic key (e.g., PROJ-123)', + condition: { field: 'operation', value: 'update' }, + mode: 'advanced', + }, { id: 'notifyUsers', title: 'Notify Users', @@ -603,6 +634,24 @@ Return ONLY the comment text - no explanations.`, condition: { field: 'operation', value: 'transition' }, mode: 'advanced', }, + { + id: 'transitionFields', + title: 'Transition Fields', + type: 'code', + language: 'json', + placeholder: '{"assignee": {"accountId": "5b10a2844c20165700ede21g"}}', + condition: { field: 'operation', value: 'transition' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a JSON object of Jira fields to set on a transition screen, keyed by field ID, based on the user's description. +Use Jira REST API v3 value shapes (e.g., "assignee": {"accountId": "..."}, "customfield_10050": {"value": "Option A"}). + +Return ONLY the JSON object - no explanations or markdown formatting.`, + placeholder: 'Describe the fields to set (e.g., "assign to the QA lead")...', + generationType: 'json-object', + }, + }, { id: 'jql', title: 'JQL Query', @@ -659,6 +708,21 @@ Return ONLY the JQL query - no explanations or markdown formatting.`, condition: { field: 'operation', value: 'search' }, mode: 'advanced', }, + { + id: 'expand', + title: 'Expand', + type: 'short-input', + placeholder: 'Comma-separated expansions (e.g., names,renderedFields,changelog)', + condition: { field: 'operation', value: 'search' }, + mode: 'advanced', + }, + { + id: 'includeAttachments', + title: 'Download Attachment Files', + type: 'switch', + condition: { field: 'operation', value: ['read', 'get_attachments'] }, + mode: 'advanced', + }, { id: 'commentBody', title: 'Comment Text', @@ -1071,6 +1135,7 @@ Return ONLY the comment text - no explanations.`, environment: params.environment || undefined, customFieldId: params.customFieldId || undefined, customFieldValue: customFieldValue, + customFields: params.customFields || undefined, } return { ...baseParams, @@ -1100,6 +1165,8 @@ Return ONLY the comment text - no explanations.`, environment: params.environment || undefined, customFieldId: params.customFieldId || undefined, customFieldValue: params.customFieldValue || undefined, + customFields: params.customFields || undefined, + parent: String(params.updateParentIssue ?? '').trim() || undefined, notifyUsers: params.notifyUsers === 'false' ? false @@ -1117,6 +1184,10 @@ Return ONLY the comment text - no explanations.`, ...baseParams, issueKey: effectiveIssueKey, ...(effectiveProjectId && { projectId: effectiveProjectId }), + includeAttachments: + params.includeAttachments === true || params.includeAttachments === 'true' + ? true + : undefined, } } case 'read-bulk': { @@ -1146,6 +1217,7 @@ Return ONLY the comment text - no explanations.`, transitionId: params.transitionId, comment: params.transitionComment, resolution: params.resolution || undefined, + fields: params.transitionFields || undefined, } } case 'search': { @@ -1160,6 +1232,7 @@ Return ONLY the comment text - no explanations.`, .map((f: string) => f.trim()) .filter(Boolean) : undefined, + expand: params.expand ? String(params.expand).trim() || undefined : undefined, } } case 'add_comment': { @@ -1196,6 +1269,10 @@ Return ONLY the comment text - no explanations.`, return { ...baseParams, issueKey: effectiveIssueKey, + includeAttachments: + params.includeAttachments === true || params.includeAttachments === 'true' + ? true + : undefined, } } case 'add_attachment': { @@ -1385,6 +1462,20 @@ Return ONLY the comment text - no explanations.`, environment: { type: 'string', description: 'Environment information' }, customFieldId: { type: 'string', description: 'Custom field ID (e.g., customfield_10001)' }, customFieldValue: { type: 'string', description: 'Value for the custom field' }, + customFields: { + type: 'json', + description: 'JSON object mapping field IDs to values for create or update', + }, + updateParentIssue: { type: 'string', description: 'New parent issue key for update' }, + transitionFields: { + type: 'json', + description: 'JSON object of fields to set during a transition', + }, + expand: { type: 'string', description: 'Comma-separated search expansions' }, + includeAttachments: { + type: 'boolean', + description: 'Download attachment file contents for read or get attachments', + }, notifyUsers: { type: 'string', description: 'Whether to send notifications on update' }, deleteSubtasks: { type: 'string', description: 'Whether to delete subtasks (true/false)' }, accountId: { @@ -1461,6 +1552,60 @@ Return ONLY the comment text - no explanations.`, status: { type: 'string', description: 'Issue status name' }, assignee: { type: 'string', description: 'Issue assignee display name or account ID' }, + descriptionAdf: { + type: 'json', + description: 'Issue description as raw Atlassian Document Format (read)', + }, + renderedDescription: { + type: 'string', + description: 'Issue description rendered as HTML (read)', + }, + environment: { type: 'string', description: 'Environment text (read)' }, + statusName: { type: 'string', description: 'Issue status name (read)' }, + priority: { type: 'json', description: 'Issue priority (id, name, iconUrl) (read)' }, + reporter: { + type: 'json', + description: 'Reporter (accountId, displayName, emailAddress, active) (read)', + }, + creator: { + type: 'json', + description: 'Creator (accountId, displayName, emailAddress, active) (read)', + }, + issuetype: { type: 'json', description: 'Issue type (id, name, subtask) (read)' }, + labels: { type: 'json', description: 'Issue labels as an array of strings (read)' }, + components: { type: 'json', description: 'Components [{id, name, description}] (read)' }, + fixVersions: { + type: 'json', + description: 'Fix versions [{id, name, released, releaseDate}] (read)', + }, + resolution: { type: 'json', description: 'Resolution (id, name, description) (read)' }, + resolutiondate: { type: 'string', description: 'When the issue was resolved (read)' }, + duedate: { type: 'string', description: 'Due date YYYY-MM-DD (read)' }, + timetracking: { + type: 'json', + description: + 'Time tracking (originalEstimate, remainingEstimate, timeSpent and their *Seconds) (read)', + }, + parent: { type: 'json', description: 'Parent issue or epic (id, key, summary) (read)' }, + subtasks: { type: 'json', description: 'Subtasks [{id, key, summary, statusName}] (read)' }, + issuelinks: { + type: 'json', + description: 'Issue links [{id, type, inwardIssue, outwardIssue}] (read)', + }, + watches: { type: 'json', description: 'Watch info (watchCount, isWatching) (read)' }, + watchers: { + type: 'json', + description: 'Watching users [{accountId, displayName, emailAddress}] (read)', + }, + customFields: { + type: 'json', + description: + 'Every customfield_* value keyed by field ID, e.g. sprint or story points (read)', + }, + names: { + type: 'json', + description: 'Map of field ID to display name (read; search when expand includes names)', + }, url: { type: 'string', description: 'URL to the created/accessed issue' }, id: { type: 'string', description: 'Jira issue ID' }, key: { type: 'string', description: 'Jira issue key' }, @@ -1473,7 +1618,8 @@ Return ONLY the comment text - no explanations.`, maxResults: { type: 'number', description: 'Maximum results per page' }, issues: { type: 'json', - description: 'Array of matching issues with key, summary, status, assignee, dates', + description: + 'Array of matching issues with key, summary, description, status, assignee, reporter, priority, labels, components, fixVersions, parent, timetracking, dates. descriptionAdf, subtasks, issuelinks, attachments, and customFields are only included when Fields is set; renderedDescription needs the renderedFields expansion', }, comments: { @@ -1489,7 +1635,11 @@ Return ONLY the comment text - no explanations.`, type: 'json', description: 'Array of attachments with id, filename, size, mimeType, created, author', }, - files: { type: 'file[]', description: 'Uploaded attachment files' }, + files: { + type: 'file[]', + description: + 'Uploaded attachment files (add attachment) or downloaded attachment files (read or get attachments with Download Attachment Files on)', + }, attachmentIds: { type: 'json', description: 'Uploaded attachment IDs' }, // jira_delete_attachment, jira_delete_comment, jira_delete_issue, jira_delete_worklog, jira_delete_issue_link outputs @@ -1563,7 +1713,7 @@ Return ONLY the comment text - no explanations.`, worklog_id: { type: 'string', description: 'Worklog ID (for worklog events)' }, time_spent: { type: 'string', description: 'Time spent (for worklog events)' }, changelog: { type: 'json', description: 'Changelog object (for update events)' }, - issue: { type: 'json', description: 'Complete issue object from webhook' }, + issue: { type: 'json', description: 'Complete raw issue object (read, webhook)' }, sprint: { type: 'json', description: 'Sprint object (for sprint events)' }, project: { type: 'json', description: 'Project object (for project events)' }, version: { type: 'json', description: 'Version object (for version events)' }, diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index 48d34480281..52b59ff2da5 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -647,6 +647,30 @@ Return ONLY the comment text - no explanations.`, value: () => 'true', condition: { field: 'operation', value: 'add_comment' }, }, + { + id: 'includePublicComments', + title: 'Include Public Comments', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + mode: 'advanced', + condition: { field: 'operation', value: 'get_comments' }, + }, + { + id: 'includeInternalComments', + title: 'Include Internal Comments', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + mode: 'advanced', + condition: { field: 'operation', value: 'get_comments' }, + }, { id: 'accountIds', title: 'Account IDs', @@ -698,10 +722,48 @@ Return ONLY the comment text - no explanations.`, { label: 'My Requests', id: 'OWNED_REQUESTS' }, { label: 'Participated', id: 'PARTICIPATED_REQUESTS' }, { label: 'Approver', id: 'APPROVER' }, + { label: 'Organization', id: 'ORGANIZATION' }, + { label: 'All My Organizations', id: 'ALL_ORGANIZATIONS' }, ], value: () => 'ALL_REQUESTS', condition: { field: 'operation', value: 'get_requests' }, }, + { + id: 'requestApprovalStatus', + title: 'Approval Status', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Pending My Approval', id: 'MY_PENDING_APPROVAL' }, + { label: 'My Approval History', id: 'MY_HISTORY_APPROVAL' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'get_requests', + and: { field: 'requestOwnership', value: 'APPROVER' }, + }, + }, + { + id: 'requestOrganizationId', + title: 'Organization ID', + type: 'short-input', + placeholder: 'Customer organization ID (e.g., 3)', + condition: { + field: 'operation', + value: 'get_requests', + and: { field: 'requestOwnership', value: 'ORGANIZATION' }, + }, + }, + { + id: 'requestTypeFilter', + title: 'Request Type ID', + type: 'short-input', + placeholder: 'Filter by request type ID (e.g., 25)', + mode: 'advanced', + condition: { field: 'operation', value: 'get_requests' }, + }, { id: 'requestStatus', title: 'Request Status', @@ -1200,6 +1262,15 @@ Return ONLY the comment text - no explanations.`, serviceDeskId: params.serviceDeskId, requestOwnership: params.requestOwnership, requestStatus: params.requestStatus, + approvalStatus: + params.requestOwnership === 'APPROVER' + ? params.requestApprovalStatus || undefined + : undefined, + organizationId: + params.requestOwnership === 'ORGANIZATION' + ? params.requestOrganizationId || undefined + : undefined, + requestTypeId: params.requestTypeFilter || undefined, searchTerm: params.searchTerm, expand: params.expand, start: toOptionalInt(params.startIndex), @@ -1225,6 +1296,8 @@ Return ONLY the comment text - no explanations.`, return { ...baseParams, issueIdOrKey: params.issueIdOrKey, + isPublic: params.includePublicComments === 'false' ? false : undefined, + internal: params.includeInternalComments === 'false' ? false : undefined, expand: params.expand, start: toOptionalInt(params.startIndex), limit: toOptionalInt(params.maxResults), @@ -1662,6 +1735,17 @@ Return ONLY the comment text - no explanations.`, requestOwnership: { type: 'string', description: 'Request ownership filter' }, requestStatus: { type: 'string', description: 'Request status filter' }, searchTerm: { type: 'string', description: 'Search term for requests' }, + requestApprovalStatus: { + type: 'string', + description: 'Approval status filter for approver requests', + }, + requestOrganizationId: { + type: 'string', + description: 'Organization ID filter for organization requests', + }, + requestTypeFilter: { type: 'string', description: 'Request type ID filter for requests' }, + includePublicComments: { type: 'string', description: 'Include public comments' }, + includeInternalComments: { type: 'string', description: 'Include internal comments' }, includeCount: { type: 'string', description: 'Include issue count for queues' }, startIndex: { type: 'string', description: 'Pagination start index' }, maxResults: { type: 'string', description: 'Maximum results to return' }, @@ -1722,6 +1806,33 @@ Return ONLY the comment text - no explanations.`, request: { type: 'json', description: 'Request object' }, requests: { type: 'json', description: 'Array of requests' }, url: { type: 'string', description: 'URL to the request' }, + webUrl: { + type: 'string', + description: 'Customer portal URL for the request (from _links.web)', + }, + requestTypeId: { type: 'string', description: 'Request type ID' }, + serviceDeskId: { type: 'string', description: 'Service desk ID' }, + createdDate: { + type: 'json', + description: 'Creation date (iso8601, friendly, epochMillis)', + }, + currentStatus: { + type: 'json', + description: 'Current request status (status, statusCategory, statusDate)', + }, + reporter: { + type: 'json', + description: 'Reporter (accountId, displayName, emailAddress, active)', + }, + requestFieldValues: { + type: 'json', + description: 'Request field values [{fieldId, label, value, renderedValue}]', + }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key the operation acted on' }, + author: { + type: 'json', + description: 'Comment author (accountId, displayName, emailAddress)', + }, commentId: { type: 'string', description: 'Comment ID' }, body: { type: 'string', description: 'Comment body' }, isPublic: { type: 'boolean', description: 'Whether comment is public' }, @@ -1739,6 +1850,21 @@ Return ONLY the comment text - no explanations.`, approval: { type: 'json', description: 'Approval object' }, approvalId: { type: 'string', description: 'Approval ID' }, decision: { type: 'string', description: 'Approval decision' }, + id: { type: 'string', description: 'Approval ID (answer approval) or form ID (attach form)' }, + finalDecision: { + type: 'string', + description: 'Final approval decision (approved, declined, pending)', + }, + canAnswerApproval: { + type: 'boolean', + description: 'Whether the current user can answer the approval', + }, + approvers: { + type: 'json', + description: + 'Approvers [{approver: {accountId, displayName, emailAddress}, approverDecision}]', + }, + completedDate: { type: 'json', description: 'Approval completion date' }, total: { type: 'number', description: 'Total count' }, isLastPage: { type: 'boolean', description: 'Whether this is the last page' }, requestTypeFields: { type: 'json', description: 'Array of request type fields' }, @@ -1765,6 +1891,7 @@ Return ONLY the comment text - no explanations.`, description: 'Form publishing and request type configuration', }, updated: { type: 'string', description: 'Last updated timestamp' }, + projectIdOrKey: { type: 'string', description: 'Project ID or key for form templates' }, forms: { type: 'json', description: @@ -1817,7 +1944,13 @@ Return ONLY the comment text - no explanations.`, description: 'Single Assets object (id, label, objectKey, objectType, attributes)', }, objectId: { type: 'string', description: 'Assets object ID (delete operation)' }, - isLast: { type: 'boolean', description: 'Whether this is the last page of schemas' }, + isLast: { + type: 'boolean', + description: 'Whether this is the last page of schemas or AQL search results', + }, + pageNumber: { type: 'number', description: 'AQL search page number' }, + pageSize: { type: 'number', description: 'Number of objects on this AQL search page' }, + startAt: { type: 'number', description: 'Index of the first object on this AQL search page' }, }, triggers: { enabled: true, diff --git a/apps/sim/blocks/blocks/kalshi.ts b/apps/sim/blocks/blocks/kalshi.ts index d3d20ce0eba..280e6eebed4 100644 --- a/apps/sim/blocks/blocks/kalshi.ts +++ b/apps/sim/blocks/blocks/kalshi.ts @@ -1,7 +1,7 @@ import { KalshiIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { createVersionedToolSelector } from '@/blocks/utils' +import { createVersionedToolSelector, withSavedCredential } from '@/blocks/utils' /* * Mutually exclusive alternates, not canonical pairs — an order carries either @@ -22,7 +22,7 @@ export const KalshiBlock: BlockConfig = { category: 'tools', integrationType: IntegrationType.Analytics, hideFromToolbar: true, - sunset: { status: 'legacy', replacedBy: 'kalshi_v2' }, + sunset: { status: 'legacy', replacedBy: 'kalshi_v3' }, bgColor: '#09C285', iconColor: '#09C285', icon: KalshiIcon, @@ -824,14 +824,14 @@ Return ONLY the numeric timestamp (seconds since Unix epoch) - no explanations, export const KalshiV2Block: BlockConfig = { ...KalshiBlock, - sunset: undefined, + sunset: { status: 'legacy', replacedBy: 'kalshi_v3' }, type: 'kalshi_v2', name: 'Kalshi', description: 'Access prediction markets and trade on Kalshi', longDescription: 'Integrate Kalshi prediction markets into the workflow. Can get markets, market, events, event, balance, positions, orders, orderbook, trades, candlesticks, fills, series, exchange status, and place/cancel/amend trades.', integrationType: IntegrationType.Analytics, - hideFromToolbar: false, + hideFromToolbar: true, tools: { ...KalshiBlock.tools, access: [ @@ -996,6 +996,24 @@ export const KalshiV2Block: BlockConfig = { }, } +/** + * Signs account and trading requests with a saved Kalshi API key; the market-data operations + * stay public and need no credential. + */ +export const KalshiV3Block: BlockConfig = { + ...KalshiV2Block, + type: 'kalshi_v3', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(KalshiV2Block, { + id: 'credential', + type: 'oauth-input', + serviceId: 'kalshi', + title: 'Kalshi Credential', + }), +} + export const KalshiBlockMeta = { tags: ['prediction-markets', 'data-analytics'], url: 'https://kalshi.com', diff --git a/apps/sim/blocks/blocks/lambda.ts b/apps/sim/blocks/blocks/lambda.ts index 8c8af02bd15..1bebcaf688b 100644 --- a/apps/sim/blocks/blocks/lambda.ts +++ b/apps/sim/blocks/blocks/lambda.ts @@ -17,6 +17,7 @@ const PARAM_KINDS = { logType: 'string', clientContext: 'string', qualifier: 'string', + tenantId: 'string', functionVersion: 'string', masterRegion: 'string', marker: 'string', @@ -47,6 +48,8 @@ const PARAM_KINDS = { snapStartApplyOn: 'string', logFormat: 'string', logGroup: 'string', + applicationLogLevel: 'string', + systemLogLevel: 'string', dryRun: 'boolean', revisionId: 'string', codeSha256: 'string', @@ -119,7 +122,15 @@ const ALL_PARAM_NAMES = Object.keys(PARAM_KINDS) as ParamName[] * executor's `{ ...inputs, ...transformedParams }` merge. */ const OPERATION_PARAMS: Record = { - invoke: ['functionName', 'payload', 'invocationType', 'logType', 'clientContext', 'qualifier'], + invoke: [ + 'functionName', + 'payload', + 'invocationType', + 'logType', + 'clientContext', + 'qualifier', + 'tenantId', + ], list_functions: ['functionVersion', 'masterRegion', 'marker', 'maxItems'], get_function: ['functionName', 'qualifier'], get_function_configuration: ['functionName', 'qualifier'], @@ -183,6 +194,8 @@ const OPERATION_PARAMS: Record = { 'snapStartApplyOn', 'logFormat', 'logGroup', + 'applicationLogLevel', + 'systemLogLevel', 'revisionId', ], delete_function: ['functionName', 'qualifier'], @@ -822,11 +835,22 @@ export const LambdaBlock: BlockConfig = { ], }, }, + { + id: 'tenantId', + title: 'Tenant ID', + type: 'short-input', + placeholder: 'Tenant identifier, for functions with tenant isolation', + mode: 'advanced', + condition: { field: 'operation', value: 'invoke' }, + }, { id: 'functionVersion', title: 'Include All Versions', type: 'dropdown', - options: [{ label: 'ALL', id: 'ALL' }], + options: [ + { label: 'Unpublished only ($LATEST)', id: '' }, + { label: 'ALL', id: 'ALL' }, + ], mode: 'advanced', condition: { field: 'operation', value: 'list_functions' }, }, @@ -916,6 +940,7 @@ export const LambdaBlock: BlockConfig = { title: 'Package Type', type: 'dropdown', options: [ + { label: 'Infer from code source', id: '' }, { label: 'Zip (.zip archive)', id: 'Zip' }, { label: 'Image (container image)', id: 'Image' }, ], @@ -1096,6 +1121,7 @@ export const LambdaBlock: BlockConfig = { title: 'X-Ray Tracing', type: 'dropdown', options: [ + { label: 'Default (unchanged on update)', id: '' }, { label: 'Pass Through', id: 'PassThrough' }, { label: 'Active', id: 'Active' }, ], @@ -1137,6 +1163,7 @@ export const LambdaBlock: BlockConfig = { title: 'SnapStart', type: 'dropdown', options: [ + { label: 'Default (unchanged on update)', id: '' }, { label: 'None', id: 'None' }, { label: 'Published Versions', id: 'PublishedVersions' }, ], @@ -1151,6 +1178,7 @@ export const LambdaBlock: BlockConfig = { title: 'Log Format', type: 'dropdown', options: [ + { label: 'Default (unchanged on update)', id: '' }, { label: 'Text', id: 'Text' }, { label: 'JSON', id: 'JSON' }, ], @@ -1171,6 +1199,35 @@ export const LambdaBlock: BlockConfig = { value: ['create_function', 'update_function_configuration'], }, }, + { + id: 'applicationLogLevel', + title: 'Application Log Level', + type: 'dropdown', + options: [ + { label: 'Unchanged', id: '' }, + { label: 'TRACE', id: 'TRACE' }, + { label: 'DEBUG', id: 'DEBUG' }, + { label: 'INFO', id: 'INFO' }, + { label: 'WARN', id: 'WARN' }, + { label: 'ERROR', id: 'ERROR' }, + { label: 'FATAL', id: 'FATAL' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: 'update_function_configuration' }, + }, + { + id: 'systemLogLevel', + title: 'System Log Level', + type: 'dropdown', + options: [ + { label: 'Unchanged', id: '' }, + { label: 'DEBUG', id: 'DEBUG' }, + { label: 'INFO', id: 'INFO' }, + { label: 'WARN', id: 'WARN' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: 'update_function_configuration' }, + }, { id: 'dryRun', title: 'Dry Run', @@ -1304,6 +1361,7 @@ export const LambdaBlock: BlockConfig = { title: 'Function URL Auth Type', type: 'dropdown', options: [ + { label: 'Not set', id: '' }, { label: 'NONE', id: 'NONE' }, { label: 'AWS_IAM', id: 'AWS_IAM' }, ], @@ -1325,6 +1383,7 @@ export const LambdaBlock: BlockConfig = { title: 'Enabled', type: 'dropdown', options: [ + { label: 'Default (enabled on create, unchanged on update)', id: '' }, { label: 'No', id: 'false' }, { label: 'Yes', id: 'true' }, ], @@ -1359,6 +1418,7 @@ export const LambdaBlock: BlockConfig = { title: 'Starting Position', type: 'dropdown', options: [ + { label: 'Not set (not used for SQS)', id: '' }, { label: 'Latest', id: 'LATEST' }, { label: 'Trim Horizon (oldest)', id: 'TRIM_HORIZON' }, { label: 'At Timestamp', id: 'AT_TIMESTAMP' }, @@ -1419,6 +1479,7 @@ export const LambdaBlock: BlockConfig = { title: 'Bisect Batch On Error', type: 'dropdown', options: [ + { label: 'Default (unchanged on update)', id: '' }, { label: 'No', id: 'false' }, { label: 'Yes', id: 'true' }, ], @@ -1566,6 +1627,7 @@ export const LambdaBlock: BlockConfig = { title: 'DocumentDB Full Document', type: 'dropdown', options: [ + { label: 'Default (unchanged on update)', id: '' }, { label: 'Default (change delta only)', id: 'Default' }, { label: 'Update Lookup (full document)', id: 'UpdateLookup' }, ], @@ -1662,6 +1724,7 @@ export const LambdaBlock: BlockConfig = { title: 'Invoke Mode', type: 'dropdown', options: [ + { label: 'Default (unchanged on update)', id: '' }, { label: 'Buffered', id: 'BUFFERED' }, { label: 'Response Stream', id: 'RESPONSE_STREAM' }, ], @@ -1676,6 +1739,7 @@ export const LambdaBlock: BlockConfig = { title: 'CORS Allow Credentials', type: 'dropdown', options: [ + { label: 'Default (unchanged on update)', id: '' }, { label: 'No', id: 'false' }, { label: 'Yes', id: 'true' }, ], @@ -1761,6 +1825,7 @@ export const LambdaBlock: BlockConfig = { title: 'Compatible Architecture', type: 'dropdown', options: [ + { label: 'Any', id: '' }, { label: 'x86_64', id: 'x86_64' }, { label: 'arm64', id: 'arm64' }, ], @@ -1923,6 +1988,7 @@ export const LambdaBlock: BlockConfig = { logType: { type: 'string', description: 'Log Type' }, clientContext: { type: 'string', description: 'Client Context' }, qualifier: { type: 'string', description: 'Qualifier' }, + tenantId: { type: 'string', description: 'Tenant ID' }, functionVersion: { type: 'string', description: 'Include All Versions' }, masterRegion: { type: 'string', description: 'Master Region' }, marker: { type: 'string', description: 'Marker' }, @@ -1953,6 +2019,8 @@ export const LambdaBlock: BlockConfig = { snapStartApplyOn: { type: 'string', description: 'SnapStart' }, logFormat: { type: 'string', description: 'Log Format' }, logGroup: { type: 'string', description: 'Log Group' }, + applicationLogLevel: { type: 'string', description: 'Application Log Level' }, + systemLogLevel: { type: 'string', description: 'System Log Level' }, dryRun: { type: 'boolean', description: 'Dry Run' }, revisionId: { type: 'string', description: 'Revision ID' }, codeSha256: { type: 'string', description: 'Code SHA256' }, @@ -2063,12 +2131,18 @@ export const LambdaBlock: BlockConfig = { }, code: { type: 'json', - description: 'Presigned download URL for the deployment package, or the container image URI', + description: + 'Deployment package location (repositoryType, location presigned download URL, imageUri, resolvedImageUri, sourceKmsKeyArn, error)', }, tags: { type: 'json', description: "The function's tags", }, + tagsError: { + type: 'json', + description: + 'Why the tags could not be read, when a partial tag-read failure occurred (errorCode, message)', + }, reservedConcurrentExecutions: { type: 'number', description: 'Concurrency reserved for this function, if any', diff --git a/apps/sim/blocks/blocks/microsoft_ad.ts b/apps/sim/blocks/blocks/microsoft_ad.ts index fb8afdc0d9f..26efed35581 100644 --- a/apps/sim/blocks/blocks/microsoft_ad.ts +++ b/apps/sim/blocks/blocks/microsoft_ad.ts @@ -18,6 +18,7 @@ const USER_ID_OPERATIONS = [ 'add_user_app_role_assignment', 'remove_user_app_role_assignment', 'list_user_devices', + 'list_user_memberships', ] /** @@ -25,7 +26,11 @@ const USER_ID_OPERATIONS = [ * addresses the user, so these are the only per-user operations that can run without a User ID, * and only when continuing from a previous page. */ -const PAGED_USER_ID_OPERATIONS = ['list_user_app_role_assignments', 'list_user_devices'] +const PAGED_USER_ID_OPERATIONS = [ + 'list_user_app_role_assignments', + 'list_user_devices', + 'list_user_memberships', +] /** Per-user operations that always require a User ID, whichever page is being fetched. */ const ALWAYS_USER_ID_OPERATIONS = USER_ID_OPERATIONS.filter( @@ -65,6 +70,7 @@ const PAGED_OPERATIONS = [ 'list_service_principals', 'list_devices', 'list_user_devices', + 'list_user_memberships', 'list_conditional_access_policies', ] @@ -169,6 +175,9 @@ export const MicrosoftAdBlock: BlockConfig = { ], delete_group: [{ text: 'Delete group', field: 'groupId', core: true }], list_group_members: [{ text: 'List members of group', field: 'groupId', core: true }], + list_user_memberships: [ + { text: 'List groups and roles for user', field: 'userId', core: true }, + ], add_group_member: [ { text: 'Add member', field: 'memberId', core: true }, { text: 'to group', field: 'groupId', core: true }, @@ -257,6 +266,7 @@ export const MicrosoftAdBlock: BlockConfig = { { label: 'List Group Members', id: 'list_group_members' }, { label: 'Add Group Member', id: 'add_group_member' }, { label: 'Remove Group Member', id: 'remove_group_member' }, + { label: 'List User Memberships', id: 'list_user_memberships' }, { label: 'Assign License', id: 'assign_license' }, { label: 'List User Licenses', id: 'list_user_licenses' }, { label: 'List Subscribed SKUs', id: 'list_subscribed_skus' }, @@ -767,6 +777,24 @@ export const MicrosoftAdBlock: BlockConfig = { value: () => 'registered', condition: { field: 'operation', value: 'list_user_devices' }, }, + { + id: 'includeManager', + title: 'Include Manager', + type: 'switch', + condition: { field: 'operation', value: 'get_user' }, + mode: 'advanced', + }, + { + id: 'membershipScope', + title: 'Membership', + type: 'dropdown', + options: [ + { label: 'Direct Memberships', id: 'direct' }, + { label: 'Include Nested Groups', id: 'transitive' }, + ], + value: () => 'direct', + condition: { field: 'operation', value: 'list_user_memberships' }, + }, { id: 'policyId', title: 'Policy ID', @@ -797,6 +825,7 @@ export const MicrosoftAdBlock: BlockConfig = { 'microsoft_ad_update_group', 'microsoft_ad_delete_group', 'microsoft_ad_list_group_members', + 'microsoft_ad_list_user_memberships', 'microsoft_ad_add_group_member', 'microsoft_ad_remove_group_member', 'microsoft_ad_assign_license', @@ -833,6 +862,12 @@ export const MicrosoftAdBlock: BlockConfig = { const values = params as Record result.filter = values[FILTER_FIELD_BY_OPERATION[params.operation]] || undefined result.search = values[SEARCH_FIELD_BY_OPERATION[params.operation]] || undefined + if (params.operation === 'get_user') { + result.includeManager = params.includeManager === true || params.includeManager === 'true' + } + if (params.operation === 'list_user_memberships') { + result.transitive = params.membershipScope === 'transitive' + } if (params.operation === 'set_password') { result.forceChangePasswordNextSignIn = params.forceChangePasswordNextSignIn !== 'false' result.forceChangePasswordNextSignInWithMfa = params.forceChangePasswordNextSignInWithMfa @@ -914,6 +949,8 @@ export const MicrosoftAdBlock: BlockConfig = { deviceSearch: { type: 'string' }, deviceFilter: { type: 'string' }, deviceRelationship: { type: 'string' }, + includeManager: { type: 'boolean' }, + membershipScope: { type: 'string' }, policyId: { type: 'string' }, policyFilter: { type: 'string' }, }, @@ -921,10 +958,18 @@ export const MicrosoftAdBlock: BlockConfig = { users: { type: 'array', description: - 'User objects (id, displayName, givenName, surname, userPrincipalName, mail, jobTitle, department, officeLocation, mobilePhone, accountEnabled)', + 'User objects (id, displayName, givenName, surname, userPrincipalName, mail, jobTitle, department, officeLocation, mobilePhone, accountEnabled, businessPhones, preferredLanguage, employeeId, employeeType, companyName, userType, usageLocation, city, country, createdDateTime, onPremisesSyncEnabled)', }, userCount: { type: 'number', description: 'Number of users returned on this page' }, - user: { type: 'json', description: 'The single user this operation created or read' }, + user: { + type: 'json', + description: 'The single user this operation created or read', + }, + manager: { + type: 'json', + description: + "Get User with Include Manager on: the user's manager (id, displayName, mail, userPrincipalName, jobTitle), or null when none is assigned", + }, userId: { type: 'string', description: 'Object ID of the user the operation acted on' }, userPrincipalName: { type: 'string', description: 'User principal name of that user' }, displayName: { type: 'string', description: 'Display name of the user the operation acted on' }, @@ -938,10 +983,17 @@ export const MicrosoftAdBlock: BlockConfig = { groupId: { type: 'string', description: 'Object ID of the group the operation acted on' }, members: { type: 'array', - description: 'Group or directory role members (id, displayName, mail, odataType)', + description: + 'Group or directory role members (id, displayName, mail, odataType, userPrincipalName)', }, memberCount: { type: 'number', description: 'Number of members returned on this page' }, memberId: { type: 'string', description: 'Object ID of the member the operation acted on' }, + memberships: { + type: 'array', + description: + 'Groups, directory roles, and administrative units the user belongs to (id, displayName, description, odataType, mail, mailEnabled, securityEnabled, groupTypes, roleTemplateId)', + }, + membershipCount: { type: 'number', description: 'Number of memberships returned on this page' }, roles: { type: 'array', description: 'Activated directory roles (id, displayName, description, roleTemplateId)', @@ -1130,7 +1182,7 @@ export const MicrosoftAdBlockMeta = { description: 'Disable an Azure AD account and strip its group memberships when someone leaves. Use for secure offboarding.', content: - '# Offboard User\n\nRevoke access for a departing user.\n\n## Steps\n1. Resolve the account with Get User by user principal name or id.\n2. Use Update User to set Account Enabled to No so the user can no longer sign in.\n3. Use List Group Members or the user record to enumerate the groups the user belongs to.\n4. For each group, call Remove Group Member with the user id.\n\n## Output\nReturn the disabled user id and the list of groups the user was removed from. Note any group where removal failed so it can be handled manually, and recommend writing the action to an audit record.', + '# Offboard User\n\nRevoke access for a departing user.\n\n## Steps\n1. Resolve the account with Get User by user principal name or id.\n2. Use Update User to set Account Enabled to No so the user can no longer sign in.\n3. Use List User Memberships with the user id to enumerate everything the user belongs to directly.\n4. For each membership whose odataType is #microsoft.graph.group, call Remove Group Member with the user id. For each one whose odataType is #microsoft.graph.directoryRole, call Remove Directory Role Member with the role id and user id. List any other types (such as administrative units) for manual follow-up.\n\n## Output\nReturn the disabled user id and the groups and directory roles the user was removed from. Note any group or role where removal failed so it can be handled manually, and recommend writing the action to an audit record.', }, { name: 'audit-group-membership', diff --git a/apps/sim/blocks/blocks/microsoft_dataverse.ts b/apps/sim/blocks/blocks/microsoft_dataverse.ts index 53c5f7e3f20..d12ccbaa543 100644 --- a/apps/sim/blocks/blocks/microsoft_dataverse.ts +++ b/apps/sim/blocks/blocks/microsoft_dataverse.ts @@ -222,6 +222,13 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, generationType: 'json-object', }, }, + { + id: 'returnRecord', + title: 'Return Updated Record', + type: 'switch', + condition: { field: 'operation', value: 'update_record' }, + mode: 'advanced', + }, // FetchXML Query { id: 'fetchXml', @@ -536,6 +543,22 @@ Return ONLY the orderby expression - no $orderby= prefix, no explanations.`, condition: { field: 'operation', value: 'list_records' }, mode: 'advanced', }, + { + id: 'pageSize', + title: 'Page Size', + type: 'short-input', + placeholder: '1-5000 (default 100 when Max Results is empty); reuse with Next Page Link', + condition: { field: 'operation', value: 'list_records' }, + mode: 'advanced', + }, + { + id: 'nextLink', + title: 'Next Page Link', + type: 'short-input', + placeholder: 'nextLink from the previous page (empty on the last page; stop when empty)', + condition: { field: 'operation', value: 'list_records' }, + mode: 'advanced', + }, { id: 'expand', title: 'Expand', @@ -662,6 +685,13 @@ Return ONLY the expand expression - no $expand= prefix, no explanations.`, } }) + for (const key of ['top', 'skip', 'pageSize'] as const) { + const value = cleanParams[key] + if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) { + cleanParams[key] = Number(value) + } + } + return cleanParams }, }, @@ -682,6 +712,16 @@ Return ONLY the expand expression - no $expand= prefix, no explanations.`, description: 'Set to "true" to include total record count in the response (list records)', }, expand: { type: 'string', description: 'Navigation properties to expand' }, + pageSize: { + type: 'string', + description: + 'Records per page for list records (1-5000); keep unchanged across Next Page Link pages', + }, + nextLink: { type: 'string', description: 'nextLink from a previous list records page' }, + returnRecord: { + type: 'boolean', + description: 'Return the updated record (update record)', + }, navigationProperty: { type: 'string', description: 'Navigation property name for associations', @@ -734,6 +774,11 @@ Return ONLY the expand expression - no $expand= prefix, no explanations.`, description: 'Total matching records server-side', }, nextLink: { type: 'string', description: 'URL for next page of results' }, + pageSize: { + type: 'number', + description: + 'Page size sent for list records (null when Max Results applied instead); pass it with nextLink for the next page', + }, created: { type: 'boolean', description: 'Whether a new record was created (upsert)' }, userId: { type: 'string', description: 'Authenticated user ID (WhoAmI)' }, businessUnitId: { type: 'string', description: 'Business unit ID (WhoAmI)' }, diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts index e87f2a0f403..c4e6be972d7 100644 --- a/apps/sim/blocks/blocks/microsoft_excel.ts +++ b/apps/sim/blocks/blocks/microsoft_excel.ts @@ -82,6 +82,7 @@ function normalizeExcelAgentParams(params: Record, separateShee 'showTotals', 'rowHidden', 'columnHidden', + 'valuesOnly', ]) result[field] = optionalBoolean(params[field]) for (const field of [ @@ -1504,6 +1505,19 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, ], value: () => '', }, + { + id: 'valuesOnly', + title: 'Used Range Cells', + type: 'dropdown', + description: 'Applies only when no cell range is set and the used range is read.', + condition: { field: 'operation', value: ['read'] }, + mode: 'advanced', + options: [ + { label: 'Cells with values', id: '' }, + { label: 'Cells with values or formatting', id: 'false' }, + ], + value: () => '', + }, { id: 'fontUnderline', title: 'Underline', @@ -2006,7 +2020,8 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, throw new Error('Sheet name is required. Please select or enter a sheet name.') } const rangeParams = { ...base, sheetName: effectiveSheetName, cellRange: trimmedRange } - if (operation === 'read') return rangeParams + if (operation === 'read') + return { ...rangeParams, valuesOnly: optionalBoolean(params.valuesOnly) } return { ...rangeParams, values: optionalJson(values), @@ -2048,6 +2063,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, numberFormat: { type: 'json', description: 'Number Formats' }, columnHidden: { type: 'string', description: 'Hide Columns' }, rowHidden: { type: 'string', description: 'Hide Rows' }, + valuesOnly: { type: 'string', description: 'Used range counts only cells with values' }, fontUnderline: { type: 'string', description: 'Underline' }, operation: { type: 'string', description: 'Operation to perform' }, fileSource: { type: 'string', description: 'File source (onedrive or sharepoint)' }, diff --git a/apps/sim/blocks/blocks/microsoft_planner.ts b/apps/sim/blocks/blocks/microsoft_planner.ts index 2a983c7354c..b5fead93b20 100644 --- a/apps/sim/blocks/blocks/microsoft_planner.ts +++ b/apps/sim/blocks/blocks/microsoft_planner.ts @@ -867,11 +867,12 @@ Return ONLY the comma-separated category keys - no explanations, no extra text.` task: { type: 'json', description: - 'The Microsoft Planner task object, including details such as id, title, description, status, due date, and assignees.', + 'The Microsoft Planner task object as returned by Graph (id, title, planId, bucketId, percentComplete, priority, startDateTime, dueDateTime, assignments keyed by user ID, appliedCategories, @odata.etag). The description and checklist live on task details.', }, tasks: { type: 'json', - description: 'Array of Microsoft Planner tasks', + description: + 'Array of Microsoft Planner tasks (id, title, planId, bucketId, percentComplete, priority, startDateTime, dueDateTime, createdDateTime, completedDateTime, hasDescription, assignments as user IDs, assignees with userId/assignedDateTime/assignedBy, appliedCategories, checklistItemCount, activeChecklistItemCount, referenceCount, previewType, orderHint, conversationThreadId, createdBy, completedBy, etag)', }, taskId: { type: 'string', @@ -904,7 +905,8 @@ Return ONLY the comma-separated category keys - no explanations, no extra text.` }, taskDetails: { type: 'json', - description: 'The Microsoft Planner task details including checklist and references', + description: + 'The Microsoft Planner task details (id, description, previewType, checklist keyed by item ID with title/isChecked/orderHint, references keyed by encoded URL with alias/type, @odata.etag for Update Task Details)', }, deleted: { type: 'boolean', diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index 9aaaf5810f2..978da9e3382 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -15,6 +15,34 @@ const CHAT_FIELD = ['chatSelector', 'manualChatId'] as const const CHANNEL_FIELD = ['channelSelector', 'manualChannelId'] as const const ATTACHMENT_FIELD = ['attachmentFiles', 'fileReferences'] as const +/** Operations that address a message in either a chat or a channel, so neither id set is required. */ +const MESSAGE_LOOKUP_OPERATIONS = ['get_message', 'set_reaction', 'unset_reaction'] as const +const TEAM_REQUIRED_OPERATIONS = [ + 'read_channel', + 'write_channel', + 'update_channel_message', + 'delete_channel_message', + 'reply_to_message', + 'list_team_members', + 'list_channel_members', + 'list_channels', +] as const +const CHAT_REQUIRED_OPERATIONS = [ + 'read_chat', + 'write_chat', + 'update_chat_message', + 'delete_chat_message', + 'list_chat_members', +] as const +const CHANNEL_REQUIRED_OPERATIONS = [ + 'read_channel', + 'write_channel', + 'update_channel_message', + 'delete_channel_message', + 'reply_to_message', + 'list_channel_members', +] as const + export const MicrosoftTeamsBlock: BlockConfig = { type: 'microsoft_teams', name: 'Microsoft Teams', @@ -178,9 +206,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { 'list_team_members', 'list_channel_members', 'list_channels', + ...MESSAGE_LOOKUP_OPERATIONS, ], }, - required: true, + required: { field: 'operation', value: [...TEAM_REQUIRED_OPERATIONS] }, }, { id: 'manualTeamId', @@ -201,9 +230,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { 'list_team_members', 'list_channel_members', 'list_channels', + ...MESSAGE_LOOKUP_OPERATIONS, ], }, - required: true, + required: { field: 'operation', value: [...TEAM_REQUIRED_OPERATIONS] }, }, { id: 'chatSelector', @@ -224,9 +254,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { 'update_chat_message', 'delete_chat_message', 'list_chat_members', + ...MESSAGE_LOOKUP_OPERATIONS, ], }, - required: true, + required: { field: 'operation', value: [...CHAT_REQUIRED_OPERATIONS] }, }, { id: 'manualChatId', @@ -244,9 +275,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { 'update_chat_message', 'delete_chat_message', 'list_chat_members', + ...MESSAGE_LOOKUP_OPERATIONS, ], }, - required: true, + required: { field: 'operation', value: [...CHAT_REQUIRED_OPERATIONS] }, }, { id: 'channelSelector', @@ -268,9 +300,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { 'delete_channel_message', 'reply_to_message', 'list_channel_members', + ...MESSAGE_LOOKUP_OPERATIONS, ], }, - required: true, + required: { field: 'operation', value: [...CHANNEL_REQUIRED_OPERATIONS] }, }, { id: 'manualChannelId', @@ -289,9 +322,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { 'delete_channel_message', 'reply_to_message', 'list_channel_members', + ...MESSAGE_LOOKUP_OPERATIONS, ], }, - required: true, + required: { field: 'operation', value: [...CHANNEL_REQUIRED_OPERATIONS] }, }, { id: 'messageId', @@ -341,12 +375,55 @@ export const MicrosoftTeamsBlock: BlockConfig = { }, required: true, }, + { + id: 'contentType', + title: 'Message Format', + type: 'dropdown', + options: [ + { label: 'Plain Text', id: 'text' }, + { label: 'HTML', id: 'html' }, + ], + value: () => 'text', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'write_chat', + 'write_channel', + 'update_chat_message', + 'update_channel_message', + 'reply_to_message', + ], + }, + }, { id: 'includeAttachments', title: 'Include Attachments', type: 'switch', condition: { field: 'operation', value: ['read_chat', 'read_channel'] }, }, + { + id: 'includeReplies', + title: 'Include Thread Replies', + type: 'switch', + condition: { field: 'operation', value: 'read_channel' }, + }, + { + id: 'top', + title: 'Page Size', + type: 'short-input', + placeholder: '50 (max 50)', + mode: 'advanced', + condition: { field: 'operation', value: ['read_chat', 'read_channel', 'list_chats'] }, + }, + { + id: 'pageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'nextPageToken from a previous run', + mode: 'advanced', + condition: { field: 'operation', value: ['read_chat', 'read_channel', 'list_chats'] }, + }, // File upload (basic mode) { id: 'attachmentFiles', @@ -571,6 +648,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { type: 'boolean', description: 'Download and include message attachments', }, + contentType: { type: 'string', description: 'Message body format: text or html' }, + includeReplies: { type: 'boolean', description: 'Include thread replies (channel reads)' }, + top: { type: 'number', description: 'Page size (1-50)' }, + pageToken: { type: 'string', description: 'nextPageToken from a previous read or list' }, }, outputs: { content: { type: 'string', description: 'Formatted message content from chat/channel' }, @@ -609,6 +690,44 @@ export const MicrosoftTeamsBlock: BlockConfig = { type: 'boolean', description: 'Whether Graph indicated additional pages beyond this response', }, + nextPageToken: { + type: 'string', + description: 'Pass as Page Token to fetch the next page; null on the last page', + }, + chatId: { type: 'string', description: 'Chat ID that was read or written' }, + teamId: { type: 'string', description: 'Team ID that was read or written' }, + attachmentCount: { type: 'number', description: 'Total number of attachments found' }, + replyToId: { type: 'string', description: 'Parent message ID (channel replies)' }, + from: { + type: 'json', + description: + 'Get Message sender identity set: user, application (bot), or device, each with id and displayName', + }, + senderId: { type: 'string', description: 'Get Message sender user or application ID' }, + senderType: { + type: 'string', + description: 'Get Message sender kind: user, application, device', + }, + subject: { type: 'string', description: 'Get Message subject' }, + summary: { type: 'string', description: 'Get Message summary (channel messages)' }, + importance: { type: 'string', description: 'Get Message importance: normal, high, urgent' }, + webUrl: { type: 'string', description: 'Get Message link in Teams' }, + contentType: { type: 'string', description: 'Get Message body type: text or html' }, + lastModifiedDateTime: { + type: 'string', + description: 'Get Message last change time (including reactions)', + }, + lastEditedDateTime: { type: 'string', description: 'Get Message last edit time' }, + deletedDateTime: { type: 'string', description: 'Get Message deletion time' }, + mentions: { + type: 'json', + description: 'Get Message mentions: id, mentionText, mentioned (user/application/...)', + }, + reactions: { + type: 'json', + description: + 'Get Message reactions: reactionType, displayName, createdDateTime, userId, userDisplayName', + }, type: { type: 'string', description: 'Type of Teams message' }, id: { type: 'string', description: 'Unique message identifier' }, timestamp: { type: 'string', description: 'Message timestamp' }, diff --git a/apps/sim/blocks/blocks/microsoft_word.ts b/apps/sim/blocks/blocks/microsoft_word.ts index 1d8f9e5600c..b746ca48e5a 100644 --- a/apps/sim/blocks/blocks/microsoft_word.ts +++ b/apps/sim/blocks/blocks/microsoft_word.ts @@ -515,7 +515,7 @@ export const MicrosoftWordBlock: BlockConfig = { metadata: { type: 'json', description: - 'Document metadata (documentId, name, mimeType, webViewLink, size, createdTime, modifiedTime)', + 'Document metadata (documentId, name, mimeType, webViewLink, size, createdTime, modifiedTime, driveId, parentFolderId, createdBy {id, displayName}, lastModifiedBy {id, displayName})', }, updatedContent: { type: 'boolean', @@ -524,7 +524,7 @@ export const MicrosoftWordBlock: BlockConfig = { documents: { type: 'json', description: - 'Word documents that matched a list or search operation ([{documentId, name, mimeType, webViewLink, size, createdTime, modifiedTime}])', + 'Word documents that matched a list or search operation ([{documentId, name, mimeType, webViewLink, size, createdTime, modifiedTime, driveId, parentFolderId, createdBy, lastModifiedBy}])', }, nextPageToken: { type: 'string', diff --git a/apps/sim/blocks/blocks/mssql.ts b/apps/sim/blocks/blocks/mssql.ts index f4074b179c7..7333e3b5b57 100644 --- a/apps/sim/blocks/blocks/mssql.ts +++ b/apps/sim/blocks/blocks/mssql.ts @@ -1,7 +1,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { MicrosoftSqlIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' -import { IntegrationType } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' const MSSQL_WAND_PROMPT = `You are an expert Microsoft SQL Server developer. Write T-SQL queries based on the user's request. @@ -67,6 +68,8 @@ Return ONLY the SQL query - no explanations, no markdown, no extra text.` export const MSSQLBlock: BlockConfig = { type: 'mssql', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'mssql_v2' }, name: 'Microsoft SQL Server', description: 'Connect to Microsoft SQL Server database', longDescription: 'Query, insert, update, delete, execute raw T-SQL, and introspect schemas.', @@ -406,6 +409,46 @@ export const MSSQLBlock: BlockConfig = { }, } +/** + * Microsoft SQL Server on a saved connection: host, port, database, login, password, and + * transport-security settings come from the credential, never the block. + */ +export const MSSQLV2Block: BlockConfig = { + ...MSSQLBlock, + type: 'mssql_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + canvasPresentation: { + defaultTitle: 'Microsoft SQL Server', + sentences: { + byOperation: { + query: [{ text: 'Run SELECT query', field: 'query', core: true }], + insert: [ + { text: 'Insert', field: 'data', core: true }, + { text: 'into', field: 'table', core: true }, + ], + update: [ + { text: 'Update rows in', field: 'table', core: true }, + { text: ', where', field: 'where' }, + { text: ', setting', field: 'data' }, + ], + delete: [ + { text: 'Delete rows from', field: 'table', core: true }, + { text: ', where', field: 'where' }, + ], + execute: [{ text: 'Execute raw T-SQL', field: 'query', core: true }], + introspect: ['Read the database schema', { text: ', under', field: 'schema' }], + }, + }, + }, + ...withSavedCredential( + MSSQLBlock, + { id: 'credential', type: 'oauth-input', serviceId: 'mssql', title: 'SQL Server Connection' }, + { required: true } + ), +} + export const MSSQLBlockMeta = { tags: ['data-analytics'], url: 'https://www.microsoft.com/en-us/sql-server', diff --git a/apps/sim/blocks/blocks/mysql.ts b/apps/sim/blocks/blocks/mysql.ts index 3f9b9205ab8..18ebb35a8e4 100644 --- a/apps/sim/blocks/blocks/mysql.ts +++ b/apps/sim/blocks/blocks/mysql.ts @@ -2,10 +2,13 @@ import { ClipboardList, Database, Search, Server, Table, Wrench } from '@sim/emc import { getErrorMessage } from '@sim/utils/errors' import { MySQLIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' -import { IntegrationType } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' export const MySQLBlock: BlockConfig = { type: 'mysql', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'mysql_v2' }, name: 'MySQL', description: 'Connect to MySQL database', longDescription: 'Query, insert, update, delete, and execute raw SQL.', @@ -412,6 +415,46 @@ Return ONLY the SQL query - no explanations, no markdown, no extra text.`, }, } +/** + * MySQL on a saved connection: host, port, database, login, password, and + * transport-security settings come from the credential, never the block. + */ +export const MySQLV2Block: BlockConfig = { + ...MySQLBlock, + type: 'mysql_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + canvasPresentation: { + defaultTitle: 'MySQL', + sentences: { + byOperation: { + query: [{ text: 'Run SELECT query', field: 'query', core: true }], + insert: [ + { text: 'Insert', field: 'data', core: true }, + { text: 'into', field: 'table', core: true }, + ], + update: [ + { text: 'Update rows in', field: 'table', core: true }, + { text: ', where', field: 'where' }, + { text: ', setting', field: 'data' }, + ], + delete: [ + { text: 'Delete rows from', field: 'table', core: true }, + { text: ', where', field: 'where' }, + ], + execute: [{ text: 'Execute raw SQL', field: 'query', core: true }], + introspect: ['Read the database schema'], + }, + }, + }, + ...withSavedCredential( + MySQLBlock, + { id: 'credential', type: 'oauth-input', serviceId: 'mysql', title: 'MySQL Connection' }, + { required: true } + ), +} + export const MySQLBlockMeta = { tags: ['data-analytics'], url: 'https://www.mysql.com', diff --git a/apps/sim/blocks/blocks/new_relic.ts b/apps/sim/blocks/blocks/new_relic.ts index 58503bef61d..18783044c72 100644 --- a/apps/sim/blocks/blocks/new_relic.ts +++ b/apps/sim/blocks/blocks/new_relic.ts @@ -4,6 +4,7 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { withSavedCredential } from '@/blocks/utils' import type { NewRelicCustomAttributes } from '@/tools/new_relic/types' +import { NEW_RELIC_REGIONS } from '@/tools/new_relic/utils' function parseCustomAttributes(value: unknown): NewRelicCustomAttributes | undefined { if (!value) return undefined @@ -77,10 +78,7 @@ export const NewRelicBlock: BlockConfig = { id: 'region', title: 'Region', type: 'dropdown', - options: [ - { label: 'US', id: 'us' }, - { label: 'EU', id: 'eu' }, - ], + options: NEW_RELIC_REGIONS.options.map(({ value, label }) => ({ id: value, label })), value: () => 'us', }, { diff --git a/apps/sim/blocks/blocks/onedrive.ts b/apps/sim/blocks/blocks/onedrive.ts index 135ece84d96..0c315112db6 100644 --- a/apps/sim/blocks/blocks/onedrive.ts +++ b/apps/sim/blocks/blocks/onedrive.ts @@ -245,6 +245,20 @@ export const OneDriveBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', value: ['create_file', 'upload'] }, }, + { + id: 'conflictBehavior', + title: 'If File Exists', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Replace existing file', id: 'replace' }, + { label: 'Keep both (rename new file)', id: 'rename' }, + { label: 'Fail', id: 'fail' }, + ], + value: () => '', + mode: 'advanced', + condition: { field: 'operation', value: ['create_file', 'upload'] }, + }, { id: 'folderName', title: 'Folder Name', @@ -314,7 +328,15 @@ export const OneDriveBlock: BlockConfig = { id: 'pageSize', title: 'Results Per Page', type: 'short-input', - placeholder: 'Number of results (default: 100, max: 1000)', + placeholder: 'Number of results per page (Graph default: 200)', + condition: { field: 'operation', value: ['list', 'search'] }, + }, + { + id: 'orderBy', + title: 'Sort By', + type: 'short-input', + placeholder: 'e.g., name, lastModifiedDateTime desc', + mode: 'advanced', condition: { field: 'operation', value: ['list', 'search'] }, }, { @@ -549,6 +571,21 @@ export const OneDriveBlock: BlockConfig = { placeholder: 'Select who can use the link', condition: { field: 'operation', value: 'create_share_link' }, }, + { + id: 'expirationDateTime', + title: 'Link Expiration', + type: 'short-input', + placeholder: '2026-12-31T23:59:59Z (leave empty for no expiration)', + mode: 'advanced', + condition: { field: 'operation', value: 'create_share_link' }, + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 UTC timestamp in yyyy-MM-ddTHH:mm:ssZ format for when the sharing link should expire, based on the user description. Return ONLY the timestamp string.', + placeholder: 'Describe when the link should expire (e.g., "in 7 days")...', + generationType: 'timestamp', + }, + }, // Delete File Fields - File Selector (basic mode) { id: 'deleteFileSelector', @@ -712,6 +749,9 @@ export const OneDriveBlock: BlockConfig = { destinationFolderId: resolvedDestinationFolderId, pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined, mimeType: mimeType, + conflictBehavior: rest.conflictBehavior || undefined, + orderBy: rest.orderBy?.trim() || undefined, + expirationDateTime: rest.expirationDateTime?.trim() || undefined, ...(downloadFileName && { fileName: downloadFileName }), } }, @@ -726,6 +766,10 @@ export const OneDriveBlock: BlockConfig = { content: { type: 'string', description: 'Text content to upload' }, mimeType: { type: 'string', description: 'MIME type of file to create' }, values: { type: 'json', description: 'Cell values for new Excel as JSON' }, + conflictBehavior: { + type: 'string', + description: 'Name-conflict behavior for upload/create file: replace, rename, or fail', + }, // Folder canonical params (per-operation) uploadFolderId: { type: 'string', description: 'Parent folder for upload/create file' }, createFolderParentId: { type: 'string', description: 'Parent folder for create folder' }, @@ -738,6 +782,7 @@ export const OneDriveBlock: BlockConfig = { // List / Search operation inputs query: { type: 'string', description: 'Search query' }, pageSize: { type: 'number', description: 'Results per page' }, + orderBy: { type: 'string', description: 'OData $orderby expression for list or search' }, pageToken: { type: 'string', description: 'Continuation URL for the next page of list or search results', @@ -757,6 +802,10 @@ export const OneDriveBlock: BlockConfig = { type: 'string', description: 'Who can use the link: anonymous, organization, or users', }, + expirationDateTime: { + type: 'string', + description: 'Sharing link expiration (yyyy-MM-ddTHH:mm:ssZ)', + }, // Get item operation inputs getItemFileId: { type: 'string', @@ -769,12 +818,13 @@ export const OneDriveBlock: BlockConfig = { fileId: { type: 'string', description: 'The ID of the deleted file' }, file: { type: 'file', - description: 'The OneDrive file object, including details such as id, name, size, and more.', + description: + 'The OneDrive file object (id, name, mimeType, webViewLink, size, createdTime, modifiedTime, parents, isFolder, childCount, eTag, parentReference {id, driveId, driveType, name, path, siteId}, createdBy {id, displayName}, lastModifiedBy {id, displayName})', }, files: { type: 'json', description: - 'An array of OneDrive file objects, each containing details such as id, name, size, and more.', + 'OneDrive file objects ([{id, name, mimeType, webViewLink, webContentLink, size, createdTime, modifiedTime, parents, isFolder, childCount, description, eTag, parentReference, createdBy, lastModifiedBy}])', }, nextPageToken: { type: 'string', @@ -794,7 +844,8 @@ export const OneDriveBlock: BlockConfig = { }, link: { type: 'json', - description: 'The created sharing link, including its type, scope, and URL', + description: + 'The created sharing link (type, scope, webUrl, webHtml, permissionId, roles, hasPassword, expirationDateTime)', }, driveId: { type: 'string', description: 'The ID of the drive' }, driveType: { diff --git a/apps/sim/blocks/blocks/outlook.test.ts b/apps/sim/blocks/blocks/outlook.test.ts index e5e4eb8c812..d9e7b52d457 100644 --- a/apps/sim/blocks/blocks/outlook.test.ts +++ b/apps/sim/blocks/blocks/outlook.test.ts @@ -54,3 +54,43 @@ describe('OutlookBlock calendar operations', () => { expect(declined?.sendResponse).toBe(false) }) }) + +/** + * The executor builds tool params as `{ ...inputs, ...transformedParams }`, so a value stored for + * another operation reaches the tool unless the mapper assigns that key explicitly. + */ +describe('OutlookBlock values stored for another operation', () => { + const mapParams = block.tools.config?.params as ( + params: Record + ) => Record + + it.each(['reply_outlook', 'reply_all_outlook', 'forward_outlook'])( + 'clears a Send cc/bcc on %s', + (operation) => { + const mapped = mapParams({ + operation, + oauthCredential: 'cred-1', + messageId: 'msg-1', + cc: 'boss@example.com', + bcc: 'audit@example.com', + }) + expect(mapped).toHaveProperty('cc', null) + expect(mapped).toHaveProperty('bcc', null) + } + ) + + it.each(['reply_outlook', 'reply_all_outlook', 'forward_outlook'])( + 'clears an Update Message importance on %s', + (operation) => { + const mapped = mapParams({ operation, oauthCredential: 'cred-1', importance: 'high' }) + expect(mapped).toHaveProperty('importance', undefined) + } + ) + + it('clears an Update Message importance on send and draft', () => { + for (const operation of ['send_outlook', 'draft_outlook']) { + const mapped = mapParams({ operation, oauthCredential: 'cred-1', importance: 'high' }) + expect(mapped).toHaveProperty('importance', null) + } + }) +}) diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index c08265c1997..2f8a5c7d6a0 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -291,6 +291,51 @@ export const OutlookBlock: BlockConfig = { mode: 'advanced', required: false, }, + { + id: 'replyTo', + title: 'Reply-To', + type: 'short-input', + placeholder: 'Addresses replies should go to (comma-separated)', + condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] }, + mode: 'advanced', + required: false, + }, + { + id: 'mailImportance', + title: 'Importance', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Low', id: 'low' }, + { label: 'Normal', id: 'normal' }, + { label: 'High', id: 'high' }, + ], + value: () => '', + condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] }, + mode: 'advanced', + required: false, + }, + { + id: 'replyCc', + title: 'CC', + type: 'short-input', + placeholder: 'CC recipients (comma-separated)', + condition: { field: 'operation', value: ['reply_outlook', 'forward_outlook'] }, + mode: 'advanced', + required: false, + }, + { + id: 'replyBcc', + title: 'BCC', + type: 'short-input', + placeholder: 'BCC recipients (comma-separated)', + condition: { + field: 'operation', + value: ['reply_outlook', 'reply_all_outlook', 'forward_outlook'], + }, + mode: 'advanced', + required: false, + }, { id: 'folderSelector', title: 'Folder', @@ -940,6 +985,9 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`, calResponseType, calComment, calSendResponse, + mailImportance, + replyCc, + replyBcc, ...rest } = params @@ -961,6 +1009,17 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`, rest.folder = effectiveFolder || 'INBOX' } + if (['send_outlook', 'draft_outlook'].includes(rest.operation)) { + rest.importance = mailImportance || null + } + + // Assign explicitly so a cc/bcc/importance stored for another operation never reaches these tools + if (['reply_outlook', 'reply_all_outlook', 'forward_outlook'].includes(rest.operation)) { + rest.cc = rest.operation === 'reply_all_outlook' ? null : replyCc || null + rest.bcc = replyBcc || null + rest.importance = undefined + } + if (rest.operation === 'search_outlook' && searchQuery) { rest.query = String(searchQuery).trim() } @@ -1123,6 +1182,13 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`, categories: { type: 'string', description: 'Comma-separated category names' }, flagStatus: { type: 'string', description: 'Follow-up flag status' }, importance: { type: 'string', description: 'Message importance level' }, + replyTo: { type: 'string', description: 'Reply-To addresses for sent or drafted email' }, + mailImportance: { + type: 'string', + description: 'Importance of a sent or drafted email (low, normal, high)', + }, + replyCc: { type: 'string', description: 'CC recipients for a reply or forward' }, + replyBcc: { type: 'string', description: 'BCC recipients for a reply, reply-all, or forward' }, calendarId: { type: 'string', description: 'Calendar to read from or write to (canonical param); blank = default calendar', @@ -1164,7 +1230,7 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`, results: { type: 'json', description: - 'Operation results. Calendar operations return the event(s): {id, subject, start, end, isAllDay, location, organizer, attendees, onlineMeeting, webLink, bodyPreview}', + 'Operation results. Read and Search return messages: {id, subject, bodyPreview, body, sender, from, toRecipients, ccRecipients, bccRecipients, replyTo, receivedDateTime, sentDateTime, hasAttachments, isRead, importance, internetMessageId, conversationId, parentFolderId, categories, flag, isDraft, webLink}. Calendar operations return the event(s): {id, subject, start, end, isAllDay, location, organizer, attendees, onlineMeeting, webLink, bodyPreview}', }, status: { type: 'string', description: 'Email send status (sent)' }, timestamp: { type: 'string', description: 'Operation timestamp' }, diff --git a/apps/sim/blocks/blocks/postgresql.ts b/apps/sim/blocks/blocks/postgresql.ts index 920632732fa..955b69f1a66 100644 --- a/apps/sim/blocks/blocks/postgresql.ts +++ b/apps/sim/blocks/blocks/postgresql.ts @@ -1,10 +1,13 @@ import { getErrorMessage } from '@sim/utils/errors' import { PostgresIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' -import { IntegrationType } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' export const PostgreSQLBlock: BlockConfig = { type: 'postgresql', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'postgresql_v2' }, name: 'PostgreSQL', description: 'Connect to PostgreSQL database', longDescription: @@ -435,6 +438,51 @@ Return ONLY the SQL query - no explanations, no markdown, no extra text.`, }, } +/** + * PostgreSQL on a saved connection: host, port, database, login, password, and + * transport-security settings come from the credential, never the block. + */ +export const PostgreSQLV2Block: BlockConfig = { + ...PostgreSQLBlock, + type: 'postgresql_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + canvasPresentation: { + defaultTitle: 'PostgreSQL', + sentences: { + byOperation: { + query: [{ text: 'Run SELECT query', field: 'query', core: true }], + insert: [ + { text: 'Insert', field: 'data', core: true }, + { text: 'into', field: 'table', core: true }, + ], + update: [ + { text: 'Update rows in', field: 'table', core: true }, + { text: ', where', field: 'where' }, + { text: ', setting', field: 'data' }, + ], + delete: [ + { text: 'Delete rows from', field: 'table', core: true }, + { text: ', where', field: 'where' }, + ], + execute: [{ text: 'Execute raw SQL', field: 'query', core: true }], + introspect: ['Read the database schema', { text: ', under', field: 'schema' }], + }, + }, + }, + ...withSavedCredential( + PostgreSQLBlock, + { + id: 'credential', + type: 'oauth-input', + serviceId: 'postgresql', + title: 'PostgreSQL Connection', + }, + { required: true } + ), +} + export const PostgreSQLBlockMeta = { tags: ['data-analytics'], url: 'https://www.postgresql.org', diff --git a/apps/sim/blocks/blocks/powerbi.ts b/apps/sim/blocks/blocks/powerbi.ts index 928ac6a4ade..772887c7c7b 100644 --- a/apps/sim/blocks/blocks/powerbi.ts +++ b/apps/sim/blocks/blocks/powerbi.ts @@ -25,7 +25,7 @@ export const PowerBIBlock: BlockConfig = { type: 'powerbi', name: 'Power BI', description: 'Query semantic models and manage reports and refreshes', - longDescription: `Connect a home-tenant organizational account through the Power BI Microsoft connection to list named workspaces, inspect reports and semantic models, execute DAX queries, request standard refreshes, and read refresh history. Microsoft Graph connections cannot substitute for this delegated OAuth connection. Self-hosted installations use the shared Microsoft app registration and register /api/auth/oauth2/callback/microsoft-powerbi as a Web redirect URI. Workspace listing supports a result limit, offset, and OData filter; reports and semantic models return flat lists. Read-only models may contain only their ID and name, and paginated reports may omit their semantic-model ID. Missing optional fields are null and do not establish permission or feature availability. Keep results small with summaries or TOPN: provider JSON reads are limited to 20 MiB, and workflow tool responses have a stricter 10 MiB inline budget. My Workspace, cross-tenant guest configuration, Credential Groups, service principals, impersonation, enhanced refresh, report exports, dashboards, and triggers are outside this version.`, + longDescription: `Connect a home-tenant organizational account through the Power BI Microsoft connection, or run as an app registration with a Microsoft app credential in the global cloud, to list named workspaces, inspect reports and semantic models, execute DAX queries, request standard refreshes, and read refresh history. A Microsoft Graph connection cannot substitute for the Power BI connection or app credential. An app credential needs the Power BI tenant setting that allows service principals to use Power BI APIs and its service principal added to each workspace as a Member or Admin; DAX queries do not run as a service principal against semantic models with row-level security or SSO. Self-hosted installations use the shared Microsoft app registration and register /api/auth/oauth2/callback/microsoft-powerbi as a Web redirect URI. Workspace listing supports a result limit, offset, and OData filter; reports and semantic models return flat lists. Read-only models may contain only their ID and name, and paginated reports may omit their semantic-model ID. Missing optional fields are null and do not establish permission or feature availability. Keep results small with summaries or TOPN: provider JSON reads are limited to 20 MiB, and workflow tool responses have a stricter 10 MiB inline budget. My Workspace, cross-tenant guest configuration, Credential Groups, impersonation, enhanced refresh, report exports, dashboards, and triggers are outside this version.`, docsLink: 'https://docs.sim.ai/integrations/powerbi', category: 'tools', integrationType: IntegrationType.Analytics, @@ -355,7 +355,8 @@ export const PowerBIBlock: BlockConfig = { }, reports: { type: 'json', - description: 'Reports with ID, name, report type, URLs, and optional semantic model ID', + description: + 'Reports with ID, name, report type, URLs, optional semantic model ID, format, isOwnedByMe, and app IDs when available', condition: { field: 'operation', value: 'powerbi_list_reports' }, }, reportCount: { @@ -365,7 +366,8 @@ export const PowerBIBlock: BlockConfig = { }, report: { type: 'json', - description: 'Report details including ID, name, report type, URLs, and semantic model ID', + description: + 'Report details including ID, name, report type, URLs, semantic model ID, format, isOwnedByMe, and app IDs when available', condition: { field: 'operation', value: 'powerbi_get_report' }, }, datasets: { diff --git a/apps/sim/blocks/blocks/qdrant.ts b/apps/sim/blocks/blocks/qdrant.ts index 476909faa04..292e8674d9f 100644 --- a/apps/sim/blocks/blocks/qdrant.ts +++ b/apps/sim/blocks/blocks/qdrant.ts @@ -1,9 +1,12 @@ import { QdrantIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' export const QdrantBlock: BlockConfig = { type: 'qdrant', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'qdrant_v2' }, name: 'Qdrant', description: 'Use Qdrant vector database', authMode: AuthMode.ApiKey, @@ -263,6 +266,21 @@ Return ONLY the JSON object.`, }, } +/** Every operation runs with a saved Qdrant API key and the instance URL it was verified against. */ +export const QdrantV2Block: BlockConfig = { + ...QdrantBlock, + type: 'qdrant_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential( + QdrantBlock, + { id: 'credential', type: 'oauth-input', serviceId: 'qdrant', title: 'Qdrant Credential' }, + // The URL shows per operation and the key in every one; one credential carries both. + { condition: null, required: true } + ), +} + export const QdrantBlockMeta = { tags: ['vector-search', 'knowledge-base'], url: 'https://qdrant.tech', diff --git a/apps/sim/blocks/blocks/railway.ts b/apps/sim/blocks/blocks/railway.ts index deb8c760d6a..3f3d397bc69 100644 --- a/apps/sim/blocks/blocks/railway.ts +++ b/apps/sim/blocks/blocks/railway.ts @@ -1,9 +1,12 @@ import { RailwayIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' export const RailwayBlock: BlockConfig = { type: 'railway', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'railway_v2' }, name: 'Railway', description: 'Manage Railway projects, services, deployments, and variables', longDescription: @@ -956,6 +959,20 @@ export const RailwayBlock: BlockConfig = { }, } +/** Every operation runs with a saved Railway token and its type. */ +export const RailwayV2Block: BlockConfig = { + ...RailwayBlock, + type: 'railway_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential( + RailwayBlock, + { id: 'credential', type: 'oauth-input', serviceId: 'railway', title: 'Railway Credential' }, + { required: true } + ), +} + export const RailwayBlockMeta = { tags: ['cloud', 'ci-cd'], url: 'https://railway.com', diff --git a/apps/sim/blocks/blocks/rds.ts b/apps/sim/blocks/blocks/rds.ts index a8b2d199ed8..89799071952 100644 --- a/apps/sim/blocks/blocks/rds.ts +++ b/apps/sim/blocks/blocks/rds.ts @@ -268,9 +268,10 @@ Output: { "name": "John Doe", "email": "john@example.com", - "is_premium": true, - "created_at": "NOW()" -}`, + "is_premium": true +} + +Values are bound as literal parameters, so SQL expressions such as NOW() are not evaluated: they are stored as text in text columns and fail on other column types. Leave out columns that should take a database default.`, placeholder: 'Describe the data you want to insert...', generationType: 'json-object', }, @@ -298,16 +299,17 @@ Return ONLY a valid JSON object containing the fields to update. Do not include 1. Only include fields that need to be updated 2. Use appropriate data types (strings in quotes, numbers without, booleans as true/false) 3. Use snake_case for field names -4. Consider including updated_at field if appropriate +4. Use literal values only; SQL expressions such as NOW() are not evaluated ### EXAMPLE User: "Update the customer to inactive and clear their subscription" Output: { "is_active": false, - "subscription_id": null, - "updated_at": "NOW()" -}`, + "subscription_id": null +} + +Values are bound as literal parameters, so SQL expressions such as NOW() are stored as text.`, placeholder: 'Describe the fields you want to update...', generationType: 'json-object', }, @@ -380,6 +382,26 @@ Return ONLY the JSON object.`, generationType: 'json-object', }, }, + { + id: 'parameters', + title: 'Query Parameters (JSON)', + type: 'code', + placeholder: '{\n "status": "active",\n "id": 5\n}', + condition: { field: 'operation', value: ['query', 'execute'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate the parameter values for an Amazon RDS Data API SQL statement based on the user's description. +Each key is a placeholder name used in the SQL as :name, and each value is the value to bind. +Example: {"status": "active", "id": 5} +To cast a string to a specific type, use an array of entries with a typeHint of DATE, DECIMAL, JSON, TIME, TIMESTAMP, or UUID: +[{"name": "created", "value": "2024-01-15 10:00:00", "typeHint": "TIMESTAMP"}] + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the values for each :placeholder...', + generationType: 'json-value', + }, + }, { id: 'schema', title: 'Schema Name', @@ -430,7 +452,7 @@ Return ONLY the JSON object.`, } }, params: (params) => { - const { operation, data, conditions, schema, engine, ...rest } = params + const { operation, data, conditions, schema, engine, parameters, ...rest } = params // Parse JSON fields const parseJson = (value: unknown, fieldName: string) => { @@ -469,6 +491,10 @@ Return ONLY the JSON object.`, if (parsedData !== undefined) result.data = parsedData if (schema) result.schema = schema if (engine) result.engine = engine + if (operation === 'query' || operation === 'execute') { + const parsedParameters = parseJson(parameters, 'parameters') + if (parsedParameters !== undefined) result.parameters = parsedParameters + } return result }, @@ -485,6 +511,10 @@ Return ONLY the JSON object.`, table: { type: 'string', description: 'Table name' }, query: { type: 'string', description: 'SQL query to execute' }, data: { type: 'json', description: 'Data for insert/update operations' }, + parameters: { + type: 'json', + description: 'Values for :name placeholders in query/execute SQL', + }, conditions: { type: 'json', description: 'Conditions for update/delete (e.g., {"id": 1})' }, schema: { type: 'string', description: 'Schema to introspect (for introspect operation)' }, engine: { @@ -503,7 +533,12 @@ Return ONLY the JSON object.`, }, rowCount: { type: 'number', - description: 'Number of rows affected by the operation', + description: 'Number of rows returned by a query, or affected by a write', + }, + generatedFields: { + type: 'array', + description: + 'Values generated by an insert or execute, such as auto-increment IDs (Aurora MySQL only)', }, engine: { type: 'string', diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 259e6d464b0..a978d261f18 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -2,6 +2,7 @@ import { ConnectIcon } from '@/components/icons' import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model-fallbacks' import { AuthMode, type BlockConfig } from '@/blocks/types' import { + BEDROCK_SAVED_CREDENTIAL_INPUTS, getModelOptions, getProviderCredentialSubBlocks, getSerializedModelProviderId, @@ -163,7 +164,7 @@ export const RouterBlock: BlockConfig = { defaultValue: 'claude-sonnet-5-5', options: getModelOptions, }, - ...getProviderCredentialSubBlocks(), + ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }), getModelFallbackSubBlock(), { id: 'temperature', @@ -200,6 +201,7 @@ export const RouterBlock: BlockConfig = { prompt: { type: 'string', description: 'Routing prompt content' }, model: { type: 'string', description: 'AI model to use' }, ...PROVIDER_CREDENTIAL_INPUTS, + ...BEDROCK_SAVED_CREDENTIAL_INPUTS, ...MODEL_FALLBACK_INPUTS, temperature: { type: 'number', @@ -254,7 +256,7 @@ export const RouterV2Block: BlockConfig = { defaultValue: 'claude-sonnet-5-5', options: getModelOptions, }, - ...getProviderCredentialSubBlocks(), + ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }), getModelFallbackSubBlock(), ], tools: { @@ -275,6 +277,7 @@ export const RouterV2Block: BlockConfig = { routes: { type: 'json', description: 'Route definitions with descriptions' }, model: { type: 'string', description: 'AI model to use' }, ...PROVIDER_CREDENTIAL_INPUTS, + ...BEDROCK_SAVED_CREDENTIAL_INPUTS, ...MODEL_FALLBACK_INPUTS, }, outputs: { diff --git a/apps/sim/blocks/blocks/s3.ts b/apps/sim/blocks/blocks/s3.ts index d6227633fba..7c3bee8f9c5 100644 --- a/apps/sim/blocks/blocks/s3.ts +++ b/apps/sim/blocks/blocks/s3.ts @@ -63,6 +63,14 @@ function s3ToolParams(params: BlockToolParams) { content: params.content, contentType: params.contentType, acl: params.acl, + metadata: params.objectMetadata, + storageClass: params.storageClass, + serverSideEncryption: params.serverSideEncryption, + sseKmsKeyId: + typeof params.serverSideEncryption === 'string' && + params.serverSideEncryption.startsWith('aws:kms') + ? params.sseKmsKeyId + : undefined, } } @@ -93,6 +101,8 @@ function s3ToolParams(params: BlockToolParams) { prefix: params.prefix, maxKeys: params.maxKeys ? Number.parseInt(params.maxKeys as string, 10) : undefined, continuationToken: params.continuationToken, + delimiter: params.delimiter, + startAfter: params.startAfter, } case 'delete_object': @@ -463,6 +473,69 @@ export const S3Block: BlockConfig = { condition: { field: 'operation', value: 'put_object' }, mode: 'advanced', }, + { + id: 'objectMetadata', + title: 'Object Metadata', + type: 'code', + language: 'json', + placeholder: '{"department": "finance", "source": "sim"}', + condition: { field: 'operation', value: 'put_object' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a flat JSON object of S3 user-defined object metadata from the description. Keys are lowercase names without the x-amz-meta- prefix and every value is a string. Return ONLY the JSON object.', + placeholder: 'Describe the metadata to attach...', + generationType: 'json-object', + }, + }, + { + id: 'storageClass', + title: 'Storage Class', + type: 'dropdown', + options: [ + { label: 'Bucket default', id: '' }, + { label: 'Standard', id: 'STANDARD' }, + { label: 'Intelligent-Tiering', id: 'INTELLIGENT_TIERING' }, + { label: 'Standard-IA', id: 'STANDARD_IA' }, + { label: 'One Zone-IA', id: 'ONEZONE_IA' }, + { label: 'Glacier Instant Retrieval', id: 'GLACIER_IR' }, + { label: 'Glacier Flexible Retrieval', id: 'GLACIER' }, + { label: 'Glacier Deep Archive', id: 'DEEP_ARCHIVE' }, + { label: 'Reduced Redundancy', id: 'REDUCED_REDUNDANCY' }, + ], + value: () => '', + placeholder: 'Select storage class (default: Standard)', + condition: { field: 'operation', value: 'put_object' }, + mode: 'advanced', + }, + { + id: 'serverSideEncryption', + title: 'Server-Side Encryption', + type: 'dropdown', + options: [ + { label: 'Bucket default', id: '' }, + { label: 'SSE-S3 (AES256)', id: 'AES256' }, + { label: 'SSE-KMS (aws:kms)', id: 'aws:kms' }, + { label: 'DSSE-KMS (aws:kms:dsse)', id: 'aws:kms:dsse' }, + ], + value: () => '', + placeholder: 'Select encryption (default: bucket setting)', + condition: { field: 'operation', value: 'put_object' }, + mode: 'advanced', + }, + { + id: 'sseKmsKeyId', + title: 'KMS Key ID', + type: 'short-input', + placeholder: 'KMS key ID or ARN (for aws:kms; default: aws/s3 managed key)', + condition: { + field: 'operation', + value: 'put_object', + and: { field: 'serverSideEncryption', value: ['aws:kms', 'aws:kms:dsse'] }, + }, + mode: 'advanced', + }, { id: 's3Uri', @@ -496,6 +569,22 @@ export const S3Block: BlockConfig = { condition: { field: 'operation', value: 'list_objects' }, mode: 'advanced', }, + { + id: 'delimiter', + title: 'Delimiter', + type: 'short-input', + placeholder: 'e.g., / to group keys into folders (commonPrefixes)', + condition: { field: 'operation', value: 'list_objects' }, + mode: 'advanced', + }, + { + id: 'startAfter', + title: 'Start After Key', + type: 'short-input', + placeholder: 'List keys after this one (e.g., folder/file-100.txt)', + condition: { field: 'operation', value: 'list_objects' }, + mode: 'advanced', + }, { id: 'objectKey', @@ -719,6 +808,10 @@ export const S3Block: BlockConfig = { content: { type: 'string', description: 'Text content to upload' }, contentType: { type: 'string', description: 'Content-Type header' }, acl: { type: 'string', description: 'Access control list' }, + objectMetadata: { type: 'json', description: 'User-defined object metadata (x-amz-meta-*)' }, + storageClass: { type: 'string', description: 'Storage class for the uploaded object' }, + serverSideEncryption: { type: 'string', description: 'Server-side encryption algorithm' }, + sseKmsKeyId: { type: 'string', description: 'KMS key ID for SSE-KMS' }, // Download inputs s3Uri: { type: 'string', description: 'S3 object URL' }, getObjectRegion: { type: 'string', description: 'Optional AWS region override for downloads' }, @@ -726,6 +819,8 @@ export const S3Block: BlockConfig = { prefix: { type: 'string', description: 'Prefix filter' }, maxKeys: { type: 'number', description: 'Maximum results' }, continuationToken: { type: 'string', description: 'Pagination token' }, + delimiter: { type: 'string', description: 'Delimiter that groups keys into common prefixes' }, + startAfter: { type: 'string', description: 'Key to start listing after' }, // Copy inputs sourceBucket: { type: 'string', description: 'Source bucket name' }, sourceKey: { type: 'string', description: 'Source object key' }, @@ -750,7 +845,15 @@ export const S3Block: BlockConfig = { description: 'S3 URI (s3://bucket/key) for use with other AWS services', }, file: { type: 'file', description: 'Downloaded file stored in execution files' }, - objects: { type: 'json', description: 'List of objects (for list operation)' }, + objects: { + type: 'json', + description: + 'List of objects (for list operation): [{key, size, lastModified, etag, storageClass}]', + }, + commonPrefixes: { + type: 'json', + description: 'Folder-like prefixes grouped by the delimiter (for list operation)', + }, buckets: { type: 'json', description: 'List of buckets (for list buckets operation)' }, deleted: { type: 'json', description: 'Deletion status (boolean) or deleted objects (array)' }, errors: { type: 'json', description: 'Failed deletions (for batch delete operation)' }, diff --git a/apps/sim/blocks/blocks/secrets_manager.ts b/apps/sim/blocks/blocks/secrets_manager.ts index ca994d14c9a..c17b40be430 100644 --- a/apps/sim/blocks/blocks/secrets_manager.ts +++ b/apps/sim/blocks/blocks/secrets_manager.ts @@ -1,8 +1,19 @@ +import { getErrorMessage } from '@sim/utils/errors' import { SecretsManagerIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { withSavedCredential } from '@/blocks/utils' +/** Parses a JSON field typed into the block, naming the field when it is invalid. */ +function parseJsonField(value: unknown, fieldName: string): unknown { + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch (error) { + throw new Error(`Invalid JSON in ${fieldName}: ${getErrorMessage(error)}`) + } +} + export const SecretsManagerBlock: BlockConfig = { type: 'secrets_manager', hideFromToolbar: true, @@ -154,6 +165,24 @@ export const SecretsManagerBlock: BlockConfig = { required: false, mode: 'advanced', }, + { + id: 'kmsKeyId', + title: 'KMS Key ID', + type: 'short-input', + placeholder: 'alias/my-key (defaults to aws/secretsmanager)', + condition: { field: 'operation', value: ['create_secret', 'update_secret'] }, + required: false, + mode: 'advanced', + }, + { + id: 'createTags', + title: 'Tags', + type: 'code', + placeholder: '[{"key":"env","value":"prod"}]', + condition: { field: 'operation', value: 'create_secret' }, + required: false, + mode: 'advanced', + }, { id: 'versionId', title: 'Version ID', @@ -190,6 +219,61 @@ export const SecretsManagerBlock: BlockConfig = { required: false, mode: 'advanced', }, + { + id: 'listFilters', + title: 'Filters', + type: 'code', + placeholder: '[{"key":"name","values":["prod/"]}]', + condition: { field: 'operation', value: 'list_secrets' }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate an AWS Secrets Manager ListSecrets filters JSON array from the user's description. +Each item is {"key": , "values": [strings]}. Values match by prefix; prefix a value with ! to negate it. + +Return ONLY the JSON array - no explanations, no markdown code blocks.`, + placeholder: 'Describe which secrets to list...', + }, + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'dropdown', + options: [ + { label: 'Default (created date)', id: '' }, + { label: 'Created Date', id: 'created-date' }, + { label: 'Last Accessed Date', id: 'last-accessed-date' }, + { label: 'Last Changed Date', id: 'last-changed-date' }, + { label: 'Name', id: 'name' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_secrets' }, + required: false, + mode: 'advanced', + }, + { + id: 'sortOrder', + title: 'Sort Order', + type: 'dropdown', + options: [ + { label: 'Default (ascending)', id: '' }, + { label: 'Ascending', id: 'asc' }, + { label: 'Descending', id: 'desc' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_secrets' }, + required: false, + mode: 'advanced', + }, + { + id: 'includePlannedDeletion', + title: 'Include Secrets Scheduled for Deletion', + type: 'switch', + condition: { field: 'operation', value: 'list_secrets' }, + required: false, + mode: 'advanced', + }, { id: 'recoveryWindowInDays', title: 'Recovery Window (Days)', @@ -359,16 +443,27 @@ export const SecretsManagerBlock: BlockConfig = { if (!Number.isNaN(parsed)) result.maxResults = parsed } if (rest.nextToken) result.nextToken = rest.nextToken + if (rest.listFilters) { + result.filters = parseJsonField(rest.listFilters, 'Filters') + } + if (rest.sortBy) result.sortBy = rest.sortBy + if (rest.sortOrder) result.sortOrder = rest.sortOrder + if (rest.includePlannedDeletion === true || rest.includePlannedDeletion === 'true') { + result.includePlannedDeletion = true + } break case 'create_secret': result.name = rest.name result.secretValue = rest.secretValue if (rest.description) result.description = rest.description + result.kmsKeyId = rest.kmsKeyId || undefined + result.tags = rest.createTags ? parseJsonField(rest.createTags, 'Tags') : undefined break case 'update_secret': result.secretId = rest.secretId result.secretValue = rest.secretValue if (rest.description) result.description = rest.description + result.kmsKeyId = rest.kmsKeyId || undefined break case 'delete_secret': result.secretId = rest.secretId @@ -383,12 +478,11 @@ export const SecretsManagerBlock: BlockConfig = { break case 'tag_resource': result.secretId = rest.secretId - result.tags = typeof rest.tags === 'string' ? JSON.parse(rest.tags) : rest.tags + result.tags = parseJsonField(rest.tags, 'Tags') break case 'untag_resource': result.secretId = rest.secretId - result.tagKeys = - typeof rest.tagKeys === 'string' ? JSON.parse(rest.tagKeys) : rest.tagKeys + result.tagKeys = parseJsonField(rest.tagKeys, 'Tag Keys') break case 'restore_secret': result.secretId = rest.secretId @@ -428,6 +522,21 @@ export const SecretsManagerBlock: BlockConfig = { versionStage: { type: 'string', description: 'Version stage (e.g., AWSCURRENT)' }, maxResults: { type: 'number', description: 'Maximum number of results to return' }, nextToken: { type: 'string', description: 'Pagination token' }, + listFilters: { + type: 'json', + description: 'ListSecrets filters, as an array of {key, values}', + }, + sortBy: { type: 'string', description: 'Field to sort listed secrets by' }, + sortOrder: { type: 'string', description: 'Sort order for listed secrets (asc or desc)' }, + includePlannedDeletion: { + type: 'boolean', + description: 'Include secrets scheduled for deletion when listing', + }, + kmsKeyId: { type: 'string', description: 'KMS key used to encrypt the secret' }, + createTags: { + type: 'json', + description: 'Tags for a new secret, as an array of {key, value} pairs', + }, recoveryWindowInDays: { type: 'number', description: 'Days before permanent deletion' }, forceDelete: { type: 'string', description: 'Force immediate deletion' }, tags: { type: 'json', description: 'Tags to attach, as an array of {key, value} pairs' }, @@ -452,6 +561,10 @@ export const SecretsManagerBlock: BlockConfig = { type: 'string', description: 'The decrypted secret value', }, + secretBinary: { + type: 'string', + description: 'Base64-encoded decrypted binary secret value, when stored as SecretBinary', + }, arn: { type: 'string', description: 'ARN of the secret', @@ -466,7 +579,8 @@ export const SecretsManagerBlock: BlockConfig = { }, secrets: { type: 'json', - description: 'List of secrets', + description: + 'List of secrets [{name, arn, description, kmsKeyId, rotationEnabled, rotationLambdaARN, rotationRules, owningService, primaryRegion, createdDate, lastChangedDate, lastAccessedDate, lastRotatedDate, nextRotationDate, deletedDate, tags, secretVersionsToStages}]', }, count: { type: 'number', @@ -509,6 +623,18 @@ export const SecretsManagerBlock: BlockConfig = { type: 'string', description: 'Date the secret was last rotated', }, + lastChangedDate: { + type: 'string', + description: 'Date the secret was last changed', + }, + lastAccessedDate: { + type: 'string', + description: 'Date the secret was last accessed', + }, + versionIdsToStages: { + type: 'json', + description: 'Map of version IDs to their staging labels', + }, nextRotationDate: { type: 'string', description: 'Date the secret is next scheduled to rotate', diff --git a/apps/sim/blocks/blocks/ses.ts b/apps/sim/blocks/blocks/ses.ts index 99cc025b3eb..ed55f6f6a02 100644 --- a/apps/sim/blocks/blocks/ses.ts +++ b/apps/sim/blocks/blocks/ses.ts @@ -324,6 +324,46 @@ export const SESBlock: BlockConfig = { required: false, mode: 'advanced', }, + { + id: 'templateReplyToAddresses', + title: 'Reply-To Addresses', + type: 'short-input', + placeholder: 'replyto@example.com', + condition: { field: 'operation', value: ['send_templated_email', 'send_bulk_email'] }, + required: false, + mode: 'advanced', + }, + { + id: 'fromName', + title: 'From Name', + type: 'short-input', + placeholder: 'Acme Support', + condition: { + field: 'operation', + value: ['send_email', 'send_templated_email', 'send_bulk_email'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'emailTags', + title: 'Email Tags (JSON)', + type: 'code', + language: 'json', + placeholder: '[{"name": "campaign", "value": "welcome"}]', + condition: { + field: 'operation', + value: ['send_email', 'send_templated_email', 'send_bulk_email'], + }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an Amazon SES message tag list as a JSON array of objects, each with a "name" and a "value". Names and values may contain only letters, digits, underscores, and hyphens, up to 256 characters. Return ONLY the JSON array.', + generationType: 'json-array', + }, + }, { id: 'defaultTemplateData', title: 'Default Template Data (JSON)', @@ -634,6 +674,28 @@ export const SESBlock: BlockConfig = { params: (params) => { const { operation, pageSize, ...rest } = params + const parseEmailTags = (value: unknown) => { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') return value + if (!value.trim()) return undefined + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new Error('Email tags must be a valid JSON array of {"name", "value"} objects') + } + if (!Array.isArray(parsed)) { + throw new Error('Email tags must be a valid JSON array of {"name", "value"} objects') + } + return parsed + } + + const applySendOptions = (target: Record, replyToAddresses: unknown) => { + target.fromName = rest.fromName || undefined + target.replyToAddresses = replyToAddresses || undefined + target.emailTags = parseEmailTags(rest.emailTags) + } + const connectionConfig = { region: rest.region, accessKeyId: rest.accessKeyId, @@ -705,8 +767,8 @@ export const SESBlock: BlockConfig = { if (rest.bodyText) result.bodyText = rest.bodyText if (rest.ccAddresses) result.ccAddresses = rest.ccAddresses if (rest.bccAddresses) result.bccAddresses = rest.bccAddresses - if (rest.replyToAddresses) result.replyToAddresses = rest.replyToAddresses if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName + applySendOptions(result, rest.replyToAddresses) break case 'send_templated_email': result.fromAddress = rest.fromAddress @@ -716,6 +778,7 @@ export const SESBlock: BlockConfig = { if (rest.ccAddresses) result.ccAddresses = rest.ccAddresses if (rest.bccAddresses) result.bccAddresses = rest.bccAddresses if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName + applySendOptions(result, rest.templateReplyToAddresses) break case 'send_bulk_email': result.fromAddress = rest.fromAddress @@ -723,6 +786,7 @@ export const SESBlock: BlockConfig = { result.destinations = rest.destinations if (rest.defaultTemplateData) result.defaultTemplateData = rest.defaultTemplateData if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName + applySendOptions(result, rest.templateReplyToAddresses) break case 'list_identities': if (pageSize != null) { @@ -783,6 +847,15 @@ export const SESBlock: BlockConfig = { ccAddresses: { type: 'string', description: 'Comma-separated CC email addresses' }, bccAddresses: { type: 'string', description: 'Comma-separated BCC email addresses' }, replyToAddresses: { type: 'string', description: 'Comma-separated reply-to addresses' }, + templateReplyToAddresses: { + type: 'string', + description: 'Comma-separated reply-to addresses for templated and bulk sends', + }, + fromName: { type: 'string', description: 'Friendly sender name shown with the From address' }, + emailTags: { + type: 'json', + description: 'Message tags as a JSON array of {name, value} objects', + }, defaultTemplateData: { type: 'string', description: 'Default JSON template data for bulk sends', @@ -949,6 +1022,17 @@ export const SESBlock: BlockConfig = { description: 'Emails sent in the last 24 hours (get_account)', condition: { field: 'operation', value: 'get_account' }, }, + productionAccessEnabled: { + type: 'boolean', + description: + 'Whether the account has production access, false in the SES sandbox (get_account)', + condition: { field: 'operation', value: 'get_account' }, + }, + enforcementStatus: { + type: 'string', + description: 'Account sending status: HEALTHY, PROBATION, or SHUTDOWN (get_account)', + condition: { field: 'operation', value: 'get_account' }, + }, templateName: { type: 'string', description: 'Template name (get_template)', diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts index 55c215fe308..7769a061467 100644 --- a/apps/sim/blocks/blocks/sharepoint.ts +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -1114,6 +1114,14 @@ export const SharepointV2Block: BlockConfig = { condition: { field: 'operation', value: 'sharepoint_list_sites' }, mode: 'advanced', }, + { + id: 'searchQuery', + title: 'Site Search', + type: 'short-input', + placeholder: 'Keywords to search sites (ignored when Group ID or a site is set)', + condition: { field: 'operation', value: 'sharepoint_list_sites' }, + mode: 'advanced', + }, { id: 'listSelector', title: 'List', @@ -1187,6 +1195,64 @@ export const SharepointV2Block: BlockConfig = { condition: { field: 'operation', value: 'sharepoint_get_list' }, mode: 'advanced', }, + { + id: 'filter', + title: 'Item Filter', + type: 'short-input', + placeholder: "OData filter, e.g. fields/Status eq 'Active'", + condition: { + field: 'operation', + value: 'sharepoint_get_list', + and: { field: 'includeColumns', value: 'true', not: true }, + }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a Microsoft Graph OData $filter expression for SharePoint list items based on the user's description. + +Reference list columns as fields/. Supported operators: eq, ne, lt, gt, le, ge, and startswith(). Quote string values with single quotes. + +Examples: +- fields/Status eq 'Active' +- fields/Quantity lt 600 +- startswith(fields/Title, 'Q3') + +Return ONLY the filter expression - no explanations, no quotes around the whole expression, no extra text.`, + placeholder: 'Describe which items to return...', + }, + }, + { + id: 'top', + title: 'Items Per Page', + type: 'short-input', + placeholder: 'Maximum items per page (1-5000)', + condition: { + field: 'operation', + value: 'sharepoint_get_list', + and: { field: 'includeColumns', value: 'true', not: true }, + }, + mode: 'advanced', + }, + { + id: 'selectFields', + title: 'Fields to Return', + type: 'short-input', + placeholder: 'Comma-separated column names, e.g. Title,Status,DueDate', + condition: { + field: 'operation', + value: 'sharepoint_get_list', + and: { field: 'includeColumns', value: 'true', not: true }, + }, + mode: 'advanced', + }, + { + id: 'itemSelectFields', + title: 'Fields to Return', + type: 'short-input', + placeholder: 'Comma-separated column names, e.g. Title,Status,DueDate', + condition: { field: 'operation', value: 'sharepoint_get_list_item' }, + mode: 'advanced', + }, { id: 'listDisplayName', title: 'List Display Name', @@ -1375,6 +1441,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, maxPages, driveId, driveItemId, + top, ...rest } = params @@ -1411,7 +1478,18 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, includeItems: coerceBoolean(includeItems), listItemFields: parseJsonObject(listItemFields), maxPages: maxPages ? Number.parseInt(String(maxPages), 10) : undefined, + top: cleanString(top) ? Number(cleanString(top)) : undefined, + } + + if (result.includeColumns === true && rest.operation === 'sharepoint_get_list') { + result.filter = undefined + result.top = undefined + result.selectFields = undefined + } + if (rest.operation === 'sharepoint_get_list_item') { + result.selectFields = cleanString(result.itemSelectFields) } + result.itemSelectFields = undefined if (columnDefinitions && rest.operation === 'sharepoint_create_list') { result.pageContent = columnDefinitions @@ -1435,7 +1513,15 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, maxPages: { type: 'number', description: 'Maximum pages to return' }, nextPageUrl: { type: 'string', description: 'Microsoft Graph @odata.nextLink URL' }, groupId: { type: 'string', description: 'Microsoft 365 group ID' }, + searchQuery: { type: 'string', description: 'Keywords to search sites by' }, listId: { type: 'string', description: 'List ID' }, + filter: { type: 'string', description: 'OData $filter for list items' }, + top: { type: 'number', description: 'Maximum list items per page' }, + selectFields: { type: 'string', description: 'Comma-separated item field names to return' }, + itemSelectFields: { + type: 'string', + description: 'Comma-separated field names to return (get list item)', + }, includeColumns: { type: 'boolean', description: 'Include columns in response' }, includeItems: { type: 'boolean', description: 'Include items in response' }, listDisplayName: { type: 'string', description: 'List display name' }, @@ -1464,7 +1550,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, }, page: { type: 'json', - description: 'SharePoint page object (id, name, title, webUrl, pageLayout, description)', + description: + 'SharePoint page object (id, name, title, webUrl, pageLayout, description, publishingState, createdBy, lastModifiedBy)', }, pages: { type: 'json', @@ -1484,12 +1571,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, type: 'json', description: 'Array of SharePoint lists (id, displayName, name, webUrl, list)', }, - item: { type: 'json', description: 'SharePoint list item with fields' }, - items: { type: 'json', description: 'Array of SharePoint list items with fields' }, + item: { + type: 'json', + description: + 'SharePoint list item (id, fields, webUrl, createdDateTime, lastModifiedDateTime, createdBy, lastModifiedBy)', + }, + items: { + type: 'json', + description: + 'Array of SharePoint list items (id, fields, webUrl, createdDateTime, lastModifiedDateTime, createdBy, lastModifiedBy)', + }, deleted: { type: 'boolean', description: 'Whether the item/page/file was deleted' }, driveItem: { type: 'json', - description: 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet)', + description: + 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet, parentReference, createdBy, lastModifiedBy)', }, file: { type: 'json', diff --git a/apps/sim/blocks/blocks/splunk.ts b/apps/sim/blocks/blocks/splunk.ts index 7019008f421..0c8a45511ff 100644 --- a/apps/sim/blocks/blocks/splunk.ts +++ b/apps/sim/blocks/blocks/splunk.ts @@ -1,6 +1,7 @@ import { SplunkIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' /** * Normalize a Splunk toggle. A dropdown supplies the strings `'true'`/`'false'`, @@ -41,6 +42,8 @@ function assignSplunkNumber(target: Record, key: string, value: export const SplunkBlock: BlockConfig = { type: 'splunk', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'splunk_v2' }, name: 'Splunk', description: 'Run SPL searches and manage saved searches and alerts in Splunk', authMode: AuthMode.ApiKey, @@ -685,6 +688,20 @@ Examples: }, } +/** Every operation runs with a saved Splunk authentication token and its management URL. */ +export const SplunkV2Block: BlockConfig = { + ...SplunkBlock, + type: 'splunk_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential( + SplunkBlock, + { id: 'credential', type: 'oauth-input', serviceId: 'splunk', title: 'Splunk Credential' }, + { required: true } + ), +} + export const SplunkBlockMeta = { tags: ['monitoring', 'data-analytics'], url: 'https://www.splunk.com', diff --git a/apps/sim/blocks/blocks/sts.ts b/apps/sim/blocks/blocks/sts.ts index 415051e257b..d520fbe56eb 100644 --- a/apps/sim/blocks/blocks/sts.ts +++ b/apps/sim/blocks/blocks/sts.ts @@ -222,6 +222,15 @@ export const STSBlock: BlockConfig = { required: false, mode: 'advanced', }, + { + id: 'sourceIdentity', + title: 'Source Identity', + type: 'short-input', + placeholder: 'alice@example.com', + condition: { field: 'operation', value: 'assume_role' }, + required: false, + mode: 'advanced', + }, { id: 'externalId', title: 'External ID', @@ -308,6 +317,7 @@ export const STSBlock: BlockConfig = { if (rest.tokenCode) result.tokenCode = rest.tokenCode if (rest.policyArns) result.policyArns = rest.policyArns if (rest.transitiveTagKeys) result.transitiveTagKeys = rest.transitiveTagKeys + result.sourceIdentity = rest.sourceIdentity || undefined if (rest.tags) { const rows = rest.tags if (typeof rows === 'string') { @@ -394,6 +404,10 @@ export const STSBlock: BlockConfig = { description: 'Comma-separated tag keys that propagate through role chaining', }, externalId: { type: 'string', description: 'External ID for cross-account access' }, + sourceIdentity: { + type: 'string', + description: 'Source identity to set on the assumed role session', + }, serialNumber: { type: 'string', description: 'MFA device serial number' }, tokenCode: { type: 'string', description: 'MFA token code' }, targetAccessKeyId: { type: 'string', description: 'Access key ID to look up' }, diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts index 7cfea118646..9fc0c6a3ec2 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { TextractIcon } from '@/components/icons' import { AuthMode, @@ -240,6 +241,72 @@ function resolveDocumentParam(value: unknown): { file?: object; filePath?: strin return {} } +interface TextractQueryParam { + Text: string + Alias?: string + Pages?: string[] +} + +/** Whether text is a JSON object, which is never a valid query list or question. */ +function parsesAsJsonObject(text: string): boolean { + if (!text.startsWith('{')) return false + try { + const parsed: unknown = JSON.parse(text) + return isRecordLike(parsed) + } catch { + return false + } +} + +/** + * Accepts a JSON array of query strings or `{Text, Alias, Pages}` objects, or + * one plain-text question per line. + */ +function parseTextractQueries(value: unknown): TextractQueryParam[] { + if (value === undefined || value === null) return [] + let raw: unknown = value + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return [] + if (trimmed.startsWith('[')) { + try { + raw = JSON.parse(trimmed) + } catch { + throw new Error('Queries must be a JSON array or one question per line') + } + } else { + if (parsesAsJsonObject(trimmed)) { + throw new Error('Queries must be a JSON array or one question per line') + } + raw = trimmed.split('\n') + } + } + if (!Array.isArray(raw)) { + throw new Error('Queries must be a JSON array or one question per line') + } + return raw.flatMap((entry): TextractQueryParam[] => { + if (typeof entry === 'string') { + const text = entry.trim() + return text ? [{ Text: text }] : [] + } + const record = + typeof entry === 'object' && entry !== null ? (entry as Record) : {} + const text = typeof record.Text === 'string' ? record.Text.trim() : '' + if (!text) { + throw new Error('Each query must be a question string or an object with a non-empty "Text"') + } + return [ + { + Text: text, + ...(typeof record.Alias === 'string' && record.Alias.trim() + ? { Alias: record.Alias.trim() } + : {}), + ...(Array.isArray(record.Pages) ? { Pages: record.Pages.map(String) } : {}), + }, + ] + }) +} + interface AwsAccessKeys { accessKeyId: string secretAccessKey: string @@ -314,6 +381,11 @@ function textractToolParams(params: BlockToolParams, accessKeys: Partial 0) { + featureTypes.push('QUERIES') + parameters.queries = queries + } if (featureTypes.length > 0) parameters.featureTypes = featureTypes return parameters @@ -475,6 +547,15 @@ export const TextractV2Block: BlockConfig = { type: 'switch' as SubBlockType, condition: { field: 'operation', value: 'analyze_document' }, }, + { + id: 'queries', + title: 'Queries', + type: 'long-input' as SubBlockType, + placeholder: + 'One question per line (e.g., What is the policy number?), or a JSON array of {"Text", "Alias", "Pages"}', + condition: { field: 'operation', value: 'analyze_document' }, + mode: 'advanced' as const, + }, ], tools: { access: ['textract_parser_v2', 'textract_analyze_expense', 'textract_analyze_id'], @@ -504,6 +585,10 @@ export const TextractV2Block: BlockConfig = { extractForms: { type: 'boolean', description: 'Extract form key-value pairs' }, detectSignatures: { type: 'boolean', description: 'Detect signatures' }, analyzeLayout: { type: 'boolean', description: 'Analyze document layout' }, + queries: { + type: 'string', + description: 'Natural-language queries (one per line or a JSON array), for analyze_document', + }, region: { type: 'string', description: 'AWS region' }, accessKeyId: { type: 'string', description: 'AWS Access Key ID' }, secretAccessKey: { type: 'string', description: 'AWS Secret Access Key' }, @@ -511,7 +596,8 @@ export const TextractV2Block: BlockConfig = { outputs: { blocks: { type: 'json', - description: 'Array of detected blocks (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.)', + description: + 'Array of detected blocks (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, QUERY, QUERY_RESULT, etc.)', condition: { field: 'operation', value: 'analyze_document' }, }, expenseDocuments: { @@ -534,6 +620,18 @@ export const TextractV2Block: BlockConfig = { type: 'string', description: 'Version of the Textract model used for processing', }, + jobStatus: { + type: 'string', + description: 'Async job status: SUCCEEDED or PARTIAL_SUCCESS (multi-page mode only)', + }, + statusMessage: { + type: 'string', + description: 'Why an async job only partially succeeded (multi-page mode only)', + }, + warnings: { + type: 'json', + description: 'Async job warnings: [{errorCode, pages}] (multi-page mode only)', + }, }, } diff --git a/apps/sim/blocks/blocks/translate.test.ts b/apps/sim/blocks/blocks/translate.test.ts new file mode 100644 index 00000000000..598dbcc5663 --- /dev/null +++ b/apps/sim/blocks/blocks/translate.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { TranslateBlock } from '@/blocks/blocks/translate' + +const toolParams = (params: Record) => { + const build = TranslateBlock.tools.config?.params + if (!build) throw new Error('TranslateBlock.tools.config.params is missing') + return build({ context: 'Hello', targetLanguage: 'French', ...params }) +} + +describe('Translate Bedrock credential', () => { + it('sends the saved AWS credential with a Bedrock model call', () => { + expect( + toolParams({ + model: 'bedrock/my-inference-profile', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + }) + ).toMatchObject({ model: 'bedrock/my-inference-profile', bedrockCredential: 'aws-cred' }) + }) + + it('refuses a Bedrock model call set to a saved AWS credential with none selected', () => { + expect(() => + toolParams({ + model: 'bedrock/my-inference-profile', + bedrockAuthMode: 'credential', + bedrockCredential: '', + }) + ).toThrow('Select an AWS credential for Bedrock') + }) + + it('ignores a leftover Bedrock credential choice once the model is not on Bedrock', () => { + const params = toolParams({ model: 'gpt-4o', bedrockAuthMode: 'credential' }) + + expect(params).toMatchObject({ model: 'gpt-4o' }) + expect(params.bedrockCredential).toBeUndefined() + }) +}) diff --git a/apps/sim/blocks/blocks/translate.ts b/apps/sim/blocks/blocks/translate.ts index edd95be15cc..11ed90308b6 100644 --- a/apps/sim/blocks/blocks/translate.ts +++ b/apps/sim/blocks/blocks/translate.ts @@ -1,10 +1,13 @@ import { TranslateIcon } from '@/components/icons' import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types' import { + BEDROCK_SAVED_CREDENTIAL_INPUTS, getModelOptions, getProviderCredentialSubBlocks, + getSelectedBedrockCredential, PROVIDER_CREDENTIAL_INPUTS, } from '@/blocks/utils' +import { findProviderFromModel } from '@/providers/models' const getTranslationPrompt = (targetLanguage: string) => `Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.` @@ -52,7 +55,7 @@ export const TranslateBlock: BlockConfig = { required: true, options: getModelOptions, }, - ...getProviderCredentialSubBlocks(), + ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }), { id: 'systemPrompt', title: 'System Prompt', @@ -80,6 +83,11 @@ export const TranslateBlock: BlockConfig = { bedrockAccessKeyId: params.bedrockAccessKeyId, bedrockSecretKey: params.bedrockSecretKey, bedrockRegion: params.bedrockRegion, + bedrockCredential: + typeof params.model === 'string' && + findProviderFromModel(params.model.trim()) === 'bedrock' + ? getSelectedBedrockCredential(params) + : undefined, }), }, }, @@ -87,6 +95,7 @@ export const TranslateBlock: BlockConfig = { context: { type: 'string', description: 'Text to translate' }, targetLanguage: { type: 'string', description: 'Target language' }, ...PROVIDER_CREDENTIAL_INPUTS, + ...BEDROCK_SAVED_CREDENTIAL_INPUTS, systemPrompt: { type: 'string', description: 'Translation instructions' }, }, outputs: { diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index 9a69478c0cb..2e1d4598243 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -495,6 +495,159 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, value: 'trello_update_card', }, }, + { + id: 'start', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD or ISO 8601 timestamp', + mode: 'advanced', + condition: { + field: 'operation', + value: ['trello_create_card', 'trello_update_card'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a date or timestamp based on the user's description. +The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. +Examples: +- "today" -> Calculate today's date in YYYY-MM-DD format +- "next Monday" -> Calculate the next Monday in YYYY-MM-DD format + +Return ONLY the date/timestamp string - no explanations, no extra text.`, + placeholder: 'Describe the start date (e.g. "next Monday")...', + generationType: 'timestamp', + }, + }, + { + id: 'updatePos', + title: 'Position', + type: 'short-input', + placeholder: 'top, bottom, or a positive float', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_card', + }, + }, + { + id: 'updateLabelIds', + title: 'Replace Label IDs', + type: 'short-input', + placeholder: 'Comma-separated label IDs (replaces existing labels)', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_card', + }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Trello label IDs. Return ONLY the comma-separated values - no explanations, no extra text.', + placeholder: 'Describe the label IDs to set...', + }, + }, + { + id: 'updateMemberIds', + title: 'Replace Member IDs', + type: 'short-input', + placeholder: 'Comma-separated member IDs (replaces existing members)', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_card', + }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Trello member IDs. Return ONLY the comma-separated values - no explanations, no extra text.', + placeholder: 'Describe the member IDs to set...', + }, + }, + { + id: 'moveCardToBoardId', + title: 'Move to Board ID', + type: 'short-input', + placeholder: 'Board ID to move the card to (set Move to List ID too)', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_card', + }, + }, + { + id: 'cardSince', + title: 'Cards Since', + type: 'short-input', + placeholder: 'ISO 8601 timestamp or card ID', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_list_cards', + }, + wandConfig: { + enabled: true, + prompt: `Generate a date or timestamp based on the user's description. +The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. +Examples: +- "1 week ago" -> Calculate the date 1 week ago in YYYY-MM-DD format + +Return ONLY the date/timestamp string - no explanations, no extra text.`, + placeholder: 'Describe the start of the range (e.g. "1 week ago")...', + generationType: 'timestamp', + }, + }, + { + id: 'cardBefore', + title: 'Cards Before', + type: 'short-input', + placeholder: 'ISO 8601 timestamp or card ID', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_list_cards', + }, + wandConfig: { + enabled: true, + prompt: `Generate a date or timestamp based on the user's description. +The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. +Examples: +- "today" -> Calculate today's date in YYYY-MM-DD format + +Return ONLY the date/timestamp string - no explanations, no extra text.`, + placeholder: 'Describe the end of the range (e.g. "today")...', + generationType: 'timestamp', + }, + }, + { + id: 'includeChecklists', + title: 'Include Checklists', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_get_card', + }, + }, + { + id: 'includeAttachments', + title: 'Include Attachments', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_get_card', + }, + }, + { + id: 'includeMembers', + title: 'Include Members', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_get_card', + }, + }, { id: 'filter', title: 'Action Filter', @@ -862,6 +1015,38 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, value: 'trello_search', }, }, + { + id: 'searchCardsPage', + title: 'Card Results Page', + type: 'short-input', + placeholder: 'Page of card results (0-100, default 0)', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_search', + }, + }, + { + id: 'searchBoardsLimit', + title: 'Board Result Limit', + type: 'short-input', + placeholder: 'Maximum number of boards to return (1-1000, default 10)', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_search', + }, + }, + { + id: 'searchPartial', + title: 'Partial Word Match', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['trello_search'], + }, + }, ], tools: { access: [ @@ -927,6 +1112,8 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, boardId, listId, filter: getTrimmedString(params.cardFilter), + since: getTrimmedString(params.cardSince), + before: getTrimmedString(params.cardBefore), } } @@ -943,6 +1130,9 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, idBoards: parseStringArray(params.searchBoardIds), modelTypes: getTrimmedString(params.searchModelTypes), cardsLimit: parseOptionalNumberInput(params.searchCardsLimit, 'cardsLimit'), + cardsPage: parseOptionalNumberInput(params.searchCardsPage, 'cardsPage'), + boardsLimit: parseOptionalNumberInput(params.searchBoardsLimit, 'boardsLimit'), + partial: parseOptionalBooleanInput(params.searchPartial), } } @@ -966,6 +1156,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, pos: getTrimmedString(params.pos), due: getTrimmedString(params.due), dueComplete: parseOptionalBooleanInput(params.dueComplete), + start: getTrimmedString(params.start), labelIds: parseStringArray(params.labelIds), memberIds: parseStringArray(params.memberIds), } @@ -987,6 +1178,11 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, idList: getTrimmedString(params.idList), due: getTrimmedString(params.due), dueComplete: parseOptionalBooleanInput(params.dueComplete), + start: getTrimmedString(params.start), + pos: getTrimmedString(params.updatePos), + idBoard: getTrimmedString(params.moveCardToBoardId), + labelIds: parseStringArray(params.updateLabelIds), + memberIds: parseStringArray(params.updateMemberIds), } } @@ -1122,6 +1318,9 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, return { ...baseParams, cardId, + includeChecklists: parseOptionalBooleanInput(params.includeChecklists), + includeAttachments: parseOptionalBooleanInput(params.includeAttachments), + includeMembers: parseOptionalBooleanInput(params.includeMembers), } } @@ -1358,6 +1557,38 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, description: 'Board IDs to restrict the search to, as an array or comma-separated string', }, searchCardsLimit: { type: 'number', description: 'Maximum number of cards to return' }, + searchCardsPage: { type: 'number', description: 'Page of card search results (0-100)' }, + searchBoardsLimit: { type: 'number', description: 'Maximum number of boards to return' }, + searchPartial: { type: 'boolean', description: 'Match the start of words in the query' }, + start: { type: 'string', description: 'Card start date in ISO 8601 format' }, + updatePos: { + type: 'string', + description: 'New card position (top, bottom, or positive float)', + }, + updateLabelIds: { + type: 'json', + description: 'Label IDs that replace the card labels, as an array or comma-separated string', + }, + updateMemberIds: { + type: 'json', + description: + 'Member IDs that replace the card members, as an array or comma-separated string', + }, + moveCardToBoardId: { type: 'string', description: 'Board ID to move the card to' }, + cardSince: { + type: 'string', + description: 'Only list cards created since this date or card ID', + }, + cardBefore: { + type: 'string', + description: 'Only list cards created before this date or card ID', + }, + includeChecklists: { + type: 'boolean', + description: 'Include checklists and items on a fetched card', + }, + includeAttachments: { type: 'boolean', description: 'Include attachments on a fetched card' }, + includeMembers: { type: 'boolean', description: 'Include member objects on a fetched card' }, }, outputs: { lists: { @@ -1367,12 +1598,12 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, cards: { type: 'json', description: - 'Cards (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', + 'Cards (id, name, desc, url, shortUrl, shortLink, idShort, idBoard, idList, closed, pos, labelIds, labels, idMembers, idChecklists, due, start, dueComplete, dateLastActivity, badges)', }, card: { type: 'json', description: - 'Created, updated, or fetched card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', + 'Created, updated, or fetched card (id, name, desc, url, shortUrl, shortLink, idShort, idBoard, idList, closed, pos, labelIds, labels, idMembers, idChecklists, due, start, dueComplete, dateLastActivity, badges, checklists, attachments, members)', }, board: { type: 'json', diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 78fea85728d..1128f5710e1 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -7,7 +7,7 @@ import { AgiloftBlock, AgiloftBlockMeta } from '@/blocks/blocks/agiloft' import { AhrefsBlock, AhrefsBlockMeta, AhrefsV2Block } from '@/blocks/blocks/ahrefs' import { AirtableBlock, AirtableBlockMeta } from '@/blocks/blocks/airtable' import { AlgoliaBlock, AlgoliaBlockMeta, AlgoliaV2Block } from '@/blocks/blocks/algolia' -import { AmplitudeBlock, AmplitudeBlockMeta } from '@/blocks/blocks/amplitude' +import { AmplitudeBlock, AmplitudeBlockMeta, AmplitudeV2Block } from '@/blocks/blocks/amplitude' import { ApiBlock } from '@/blocks/blocks/api' import { ApiTriggerBlock } from '@/blocks/blocks/api_trigger' import { ApifyBlock, ApifyBlockMeta, ApifyV2Block } from '@/blocks/blocks/apify' @@ -21,6 +21,7 @@ import { AttioBlock, AttioBlockMeta } from '@/blocks/blocks/attio' import { AzureDataExplorerBlock, AzureDataExplorerBlockMeta, + AzureDataExplorerV2Block, } from '@/blocks/blocks/azure_data_explorer' import { AzureDevOpsBlock, @@ -40,7 +41,7 @@ import { import { BufferBlock, BufferBlockMeta, BufferV2Block } from '@/blocks/blocks/buffer' import { CalComBlock, CalComBlockMeta } from '@/blocks/blocks/calcom' import { CalendlyBlock, CalendlyBlockMeta, CalendlyV2Block } from '@/blocks/blocks/calendly' -import { CbInsightsBlock, CbInsightsBlockMeta } from '@/blocks/blocks/cbinsights' +import { CbInsightsBlock, CbInsightsBlockMeta, CbInsightsV2Block } from '@/blocks/blocks/cbinsights' import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger' import { CheckrBlock, CheckrBlockMeta, CheckrV2Block } from '@/blocks/blocks/checkr' import { CirclebackBlock, CirclebackBlockMeta, CirclebackV2Block } from '@/blocks/blocks/circleback' @@ -235,6 +236,7 @@ import { KalshiBlockMeta, KalshiV2Block, KalshiV2BlockMeta, + KalshiV3Block, } from '@/blocks/blocks/kalshi' import { KetchBlock, KetchBlockMeta } from '@/blocks/blocks/ketch' import { KnowledgeBlock } from '@/blocks/blocks/knowledge' @@ -296,8 +298,8 @@ import { ModalBlock, ModalBlockMeta } from '@/blocks/blocks/modal' import { MondayBlock, MondayBlockMeta } from '@/blocks/blocks/monday' import { MongoDBBlock, MongoDBBlockMeta } from '@/blocks/blocks/mongodb' import { MothershipBlock } from '@/blocks/blocks/mothership' -import { MSSQLBlock, MSSQLBlockMeta } from '@/blocks/blocks/mssql' -import { MySQLBlock, MySQLBlockMeta } from '@/blocks/blocks/mysql' +import { MSSQLBlock, MSSQLBlockMeta, MSSQLV2Block } from '@/blocks/blocks/mssql' +import { MySQLBlock, MySQLBlockMeta, MySQLV2Block } from '@/blocks/blocks/mysql' import { Neo4jBlock, Neo4jBlockMeta } from '@/blocks/blocks/neo4j' import { NetSuiteBlock, NetSuiteBlockMeta } from '@/blocks/blocks/netsuite' import { NeverBounceBlock, NeverBounceBlockMeta } from '@/blocks/blocks/neverbounce' @@ -337,18 +339,18 @@ import { PlanetScaleV2Block, } from '@/blocks/blocks/planetscale' import { PolymarketBlock, PolymarketBlockMeta } from '@/blocks/blocks/polymarket' -import { PostgreSQLBlock, PostgreSQLBlockMeta } from '@/blocks/blocks/postgresql' +import { PostgreSQLBlock, PostgreSQLBlockMeta, PostgreSQLV2Block } from '@/blocks/blocks/postgresql' import { PostHogBlock, PostHogBlockMeta } from '@/blocks/blocks/posthog' import { PowerBIBlock, PowerBIBlockMeta } from '@/blocks/blocks/powerbi' import { ProfoundBlock, ProfoundBlockMeta, ProfoundV2Block } from '@/blocks/blocks/profound' import { ProspeoBlock, ProspeoBlockMeta } from '@/blocks/blocks/prospeo' import { PulseBlock, PulseBlockMeta, PulseV2Block, PulseV3Block } from '@/blocks/blocks/pulse' -import { QdrantBlock, QdrantBlockMeta } from '@/blocks/blocks/qdrant' +import { QdrantBlock, QdrantBlockMeta, QdrantV2Block } from '@/blocks/blocks/qdrant' import { QuartrBlock, QuartrBlockMeta, QuartrV2Block } from '@/blocks/blocks/quartr' import { QuickBooksBlock, QuickBooksBlockMeta } from '@/blocks/blocks/quickbooks' import { QuiverBlock, QuiverBlockMeta, QuiverV2Block, QuiverV3Block } from '@/blocks/blocks/quiver' import { RabbitmqBlock, RabbitmqBlockMeta } from '@/blocks/blocks/rabbitmq' -import { RailwayBlock, RailwayBlockMeta } from '@/blocks/blocks/railway' +import { RailwayBlock, RailwayBlockMeta, RailwayV2Block } from '@/blocks/blocks/railway' import { RampBlock, RampBlockMeta } from '@/blocks/blocks/ramp' import { RB2BBlock, RB2BBlockMeta, RB2BV2Block } from '@/blocks/blocks/rb2b' import { RDSBlock, RDSBlockMeta, RDSV2Block } from '@/blocks/blocks/rds' @@ -402,7 +404,7 @@ import { SlackBlock, SlackBlockMeta, SlackV2Block, SlackV2BlockMeta } from '@/bl import { SmartleadBlock, SmartleadBlockMeta, SmartleadV2Block } from '@/blocks/blocks/smartlead' import { SmtpBlock, SmtpBlockMeta } from '@/blocks/blocks/smtp' import { SnowflakeBlock, SnowflakeBlockMeta } from '@/blocks/blocks/snowflake' -import { SplunkBlock, SplunkBlockMeta } from '@/blocks/blocks/splunk' +import { SplunkBlock, SplunkBlockMeta, SplunkV2Block } from '@/blocks/blocks/splunk' import { SportmonksBlock, SportmonksBlockMeta, SportmonksV2Block } from '@/blocks/blocks/sportmonks' import { SpotifyBlock, SpotifyBlockMeta } from '@/blocks/blocks/spotify' import { SQSBlock, SQSBlockMeta, SQSV2Block } from '@/blocks/blocks/sqs' @@ -503,6 +505,7 @@ export const BLOCK_REGISTRY: Record = { algolia: AlgoliaBlock, algolia_v2: AlgoliaV2Block, amplitude: AmplitudeBlock, + amplitude_v2: AmplitudeV2Block, api: ApiBlock, api_trigger: ApiTriggerBlock, apify: ApifyBlock, @@ -519,6 +522,7 @@ export const BLOCK_REGISTRY: Record = { athena_v2: AthenaV2Block, attio: AttioBlock, azure_data_explorer: AzureDataExplorerBlock, + azure_data_explorer_v2: AzureDataExplorerV2Block, azure_devops: AzureDevOpsBlock, azure_devops_v2: AzureDevOpsV2Block, bitbucket: BitbucketBlock, @@ -537,6 +541,7 @@ export const BLOCK_REGISTRY: Record = { calendly: CalendlyBlock, calendly_v2: CalendlyV2Block, cbinsights: CbInsightsBlock, + cbinsights_v2: CbInsightsV2Block, chat_trigger: ChatTriggerBlock, checkr: CheckrBlock, checkr_v2: CheckrV2Block, @@ -725,6 +730,7 @@ export const BLOCK_REGISTRY: Record = { jupyter_v3: JupyterV3Block, kalshi: KalshiBlock, kalshi_v2: KalshiV2Block, + kalshi_v3: KalshiV3Block, ketch: KetchBlock, knowledge: KnowledgeBlock, lambda: LambdaBlock, @@ -781,7 +787,9 @@ export const BLOCK_REGISTRY: Record = { mongodb: MongoDBBlock, mothership: MothershipBlock, mssql: MSSQLBlock, + mssql_v2: MSSQLV2Block, mysql: MySQLBlock, + mysql_v2: MySQLV2Block, neo4j: Neo4jBlock, netsuite: NetSuiteBlock, new_relic: NewRelicBlock, @@ -817,6 +825,7 @@ export const BLOCK_REGISTRY: Record = { planetscale_v2: PlanetScaleV2Block, polymarket: PolymarketBlock, postgresql: PostgreSQLBlock, + postgresql_v2: PostgreSQLV2Block, posthog: PostHogBlock, powerbi: PowerBIBlock, profound: ProfoundBlock, @@ -826,6 +835,7 @@ export const BLOCK_REGISTRY: Record = { pulse_v2: PulseV2Block, pulse_v3: PulseV3Block, qdrant: QdrantBlock, + qdrant_v2: QdrantV2Block, quartr: QuartrBlock, quartr_v2: QuartrV2Block, quickbooks: QuickBooksBlock, @@ -834,6 +844,7 @@ export const BLOCK_REGISTRY: Record = { quiver_v3: QuiverV3Block, rabbitmq: RabbitmqBlock, railway: RailwayBlock, + railway_v2: RailwayV2Block, ramp: RampBlock, rb2b: RB2BBlock, rb2b_v2: RB2BV2Block, @@ -897,6 +908,7 @@ export const BLOCK_REGISTRY: Record = { smtp: SmtpBlock, snowflake: SnowflakeBlock, splunk: SplunkBlock, + splunk_v2: SplunkV2Block, sportmonks: SportmonksBlock, sportmonks_v2: SportmonksV2Block, spotify: SpotifyBlock, diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index c5c354a315f..5f038bd3fb2 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -59,9 +59,12 @@ vi.mock('@/stores/providers/store', () => ({ vi.mock('@/lib/oauth/utils', () => oauthUtilsMock) import { S3Icon } from '@/components/icons' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' import type { BlockConfig } from '@/blocks/types' import { getApiKeyCondition, + getProviderCredentialSubBlocks, + getSelectedBedrockCredential, getSerializedModelProviderId, parseOptionalBooleanInput, parseOptionalJsonInput, @@ -102,6 +105,96 @@ describe('requiresProviderFamilyCredentials', () => { }) }) +describe('Bedrock credential fields', () => { + const BEDROCK_FIELDS = [ + 'bedrockAuthMode', + 'bedrockCredential', + 'bedrockManualCredential', + 'bedrockAccessKeyId', + 'bedrockSecretKey', + 'bedrockRegion', + ] + + const visibleBedrockFields = ( + values: Record, + subBlocks = getProviderCredentialSubBlocks({ bedrockSavedCredential: true }) + ) => + subBlocks + .filter((subBlock) => BEDROCK_FIELDS.includes(subBlock.id)) + .filter((subBlock) => evaluateSubBlockCondition(subBlock.condition, values)) + .map((subBlock) => subBlock.id) + + it('keeps showing access keys, and no credential picker, on a block saved before auth modes', () => { + expect(visibleBedrockFields({ model: 'bedrock/my-inference-profile' })).toEqual([ + 'bedrockAuthMode', + 'bedrockAccessKeyId', + 'bedrockSecretKey', + 'bedrockRegion', + ]) + }) + + it('swaps the access keys for the AWS credential picker when a saved credential is chosen', () => { + expect( + visibleBedrockFields({ model: 'bedrock/my-inference-profile', bedrockAuthMode: 'credential' }) + ).toEqual(['bedrockAuthMode', 'bedrockCredential', 'bedrockManualCredential', 'bedrockRegion']) + }) + + it('shows no Bedrock field for a model of another provider', () => { + expect(visibleBedrockFields({ model: 'gpt-4o', bedrockAuthMode: 'credential' })).toEqual([]) + }) + + it('offers no saved credential, and keeps the access keys, for a block that cannot forward one', () => { + expect( + visibleBedrockFields( + { model: 'bedrock/my-inference-profile', bedrockAuthMode: 'credential' }, + getProviderCredentialSubBlocks() + ) + ).toEqual(['bedrockAccessKeyId', 'bedrockSecretKey', 'bedrockRegion']) + }) +}) + +describe('getSelectedBedrockCredential', () => { + it('returns the trimmed saved credential a block signs Bedrock calls with', () => { + expect( + getSelectedBedrockCredential({ bedrockAuthMode: 'credential', bedrockCredential: ' cred-1 ' }) + ).toBe('cred-1') + }) + + it('refuses a block set to a saved credential whose ID resolves blank', () => { + expect(() => + getSelectedBedrockCredential({ bedrockAuthMode: 'credential', bedrockCredential: '' }) + ).toThrow('Select an AWS credential for Bedrock') + expect(() => getSelectedBedrockCredential({ bedrockAuthMode: 'credential' })).toThrow( + 'Select an AWS credential for Bedrock' + ) + }) + + it('returns nothing for a block on access keys or saved before auth modes', () => { + expect( + getSelectedBedrockCredential({ bedrockAuthMode: 'access_keys', bedrockCredential: 'cred-1' }) + ).toBeUndefined() + expect(getSelectedBedrockCredential({})).toBeUndefined() + }) + + it('signs with a credential exactly when the block shows and serializes the credential picker', () => { + const picker = getProviderCredentialSubBlocks({ bedrockSavedCredential: true }).find( + (subBlock) => subBlock.id === 'bedrockCredential' + ) + const cases: Record[] = [ + { bedrockCredential: 'cred-1' }, + { bedrockAuthMode: '', bedrockCredential: 'cred-1' }, + { bedrockAuthMode: 'access_keys', bedrockCredential: 'cred-1' }, + { bedrockAuthMode: 'credential', bedrockCredential: 'cred-1' }, + ] + for (const values of cases) { + const withModel = { model: 'bedrock/my-inference-profile', ...values } + expect(getSelectedBedrockCredential(values) !== undefined, JSON.stringify(values)).toBe( + evaluateSubBlockCondition(picker?.condition, withModel) + ) + } + }) +}) + describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { const evaluateCondition = (model: string): boolean => { const conditionFn = getApiKeyCondition() diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 03671ec002f..68cbe8cbf92 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -62,6 +62,7 @@ interface SavedCredentialPicker { type: 'oauth-input' serviceId: SavedCredentialService title: string + credentialKind?: 'service-account' | 'any' } interface SavedCredentialOptions { @@ -160,7 +161,7 @@ export function withSavedCredential( 0, { ...picker, - credentialKind: 'service-account', + credentialKind: picker.credentialKind ?? 'service-account', canonicalParamId: 'oauthCredential', mode: 'basic', // 'AWS Credential' → 'Select AWS credential': sentence case, brand casing kept. @@ -511,13 +512,145 @@ function getModelProviderCondition(...providerIds: ProviderId[]) { } } +/** + * Whether a block's Bedrock call signs with a saved AWS credential. The one rule + * behind both the fields a block shows and serializes and the credential its + * call uses: only an explicit `bedrockAuthMode: 'credential'` selects one, so a + * block saved before the mode existed (no `bedrockAuthMode`) keeps its access keys. + */ +function usesSavedBedrockCredential(values: { bedrockAuthMode?: unknown } | undefined): boolean { + return values?.bedrockAuthMode === 'credential' +} + +/** + * Bedrock fields shown for one AWS authentication mode, decided by + * {@link usesSavedBedrockCredential}. Like {@link getModelProviderCondition}, the + * answer is folded into a single `model` clause. + */ +function getBedrockAuthModeCondition(mode: 'access_keys' | 'credential') { + return (values?: Record) => { + const model = typeof values?.model === 'string' ? values.model : '' + return buildModelVisibilityCondition( + model, + findProviderFromModel(model.trim()) === 'bedrock' && + usesSavedBedrockCredential(values) === (mode === 'credential') + ) + } +} + +/** + * The saved AWS credential ID a block's Bedrock call signs with, or undefined + * when the block uses access keys (see {@link usesSavedBedrockCredential}). A + * block set to a saved credential whose ID resolves blank (an empty reference) + * is refused, so its call never falls back to inline keys or the deployment's + * own AWS credentials. + */ +export function getSelectedBedrockCredential(values: { + bedrockAuthMode?: unknown + bedrockCredential?: unknown +}): string | undefined { + if (!usesSavedBedrockCredential(values)) return undefined + const credentialId = + typeof values.bedrockCredential === 'string' ? values.bedrockCredential.trim() : '' + if (credentialId) return credentialId + throw new Error( + 'Select an AWS credential for Bedrock, or switch AWS Authentication to access keys' + ) +} + +/** + * The Bedrock fields of {@link getProviderCredentialSubBlocks}. With + * `savedCredential`, an AWS Authentication choice swaps the access keys for a + * saved AWS credential; without it the block keeps its access keys alone. + */ +function getBedrockCredentialSubBlocks(savedCredential: boolean): SubBlockConfig[] { + const accessKeyCondition = savedCredential + ? getBedrockAuthModeCondition('access_keys') + : getModelProviderCondition('bedrock') + const savedCredentialSubBlocks: SubBlockConfig[] = [ + { + id: 'bedrockAuthMode', + title: 'AWS Authentication', + type: 'dropdown', + options: [ + { label: 'Access keys', id: 'access_keys' }, + { label: 'Saved AWS credential', id: 'credential' }, + ], + value: () => 'access_keys', + connectionDroppable: false, + condition: getModelProviderCondition('bedrock'), + }, + { + id: 'bedrockCredential', + title: 'AWS Credential', + type: 'oauth-input', + serviceId: 'aws', + canonicalParamId: 'bedrockCredential', + credentialKind: 'service-account', + mode: 'basic', + placeholder: 'Select AWS credential', + required: true, + condition: getBedrockAuthModeCondition('credential'), + }, + { + id: 'bedrockManualCredential', + title: 'AWS Credential', + type: 'short-input', + canonicalParamId: 'bedrockCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + condition: getBedrockAuthModeCondition('credential'), + }, + ] + return [ + ...(savedCredential ? savedCredentialSubBlocks : []), + { + id: 'bedrockAccessKeyId', + title: 'AWS Access Key ID', + type: 'short-input', + password: true, + placeholder: 'Enter your AWS Access Key ID', + connectionDroppable: false, + required: true, + hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS', + condition: accessKeyCondition, + }, + { + id: 'bedrockSecretKey', + title: 'AWS Secret Access Key', + type: 'short-input', + password: true, + placeholder: 'Enter your AWS Secret Access Key', + connectionDroppable: false, + required: true, + hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS', + condition: accessKeyCondition, + }, + { + id: 'bedrockRegion', + title: 'AWS Region', + type: 'short-input', + placeholder: 'us-east-1', + connectionDroppable: false, + condition: getModelProviderCondition('bedrock'), + }, + ] +} + /** * Returns the standard provider credential subblocks used by LLM-based blocks. * This includes: Vertex AI OAuth, API Key, Azure (OpenAI + Anthropic), Vertex AI config, and Bedrock config. * + * Pass `bedrockSavedCredential` only from a block that forwards + * {@link getSelectedBedrockCredential} to its Bedrock call and spreads + * {@link BEDROCK_SAVED_CREDENTIAL_INPUTS}; any other block keeps Bedrock access keys only. + * * Usage: Spread into your block's subBlocks array after block-specific fields */ -export function getProviderCredentialSubBlocks(): SubBlockConfig[] { +export function getProviderCredentialSubBlocks( + options: { bedrockSavedCredential?: boolean } = {} +): SubBlockConfig[] { return [ { id: 'vertexCredential', @@ -590,36 +723,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: getModelProviderCondition('vertex'), }, - { - id: 'bedrockAccessKeyId', - title: 'AWS Access Key ID', - type: 'short-input', - password: true, - placeholder: 'Enter your AWS Access Key ID', - connectionDroppable: false, - required: true, - hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS', - condition: getModelProviderCondition('bedrock'), - }, - { - id: 'bedrockSecretKey', - title: 'AWS Secret Access Key', - type: 'short-input', - password: true, - placeholder: 'Enter your AWS Secret Access Key', - connectionDroppable: false, - required: true, - hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS', - condition: getModelProviderCondition('bedrock'), - }, - { - id: 'bedrockRegion', - title: 'AWS Region', - type: 'short-input', - placeholder: 'us-east-1', - connectionDroppable: false, - condition: getModelProviderCondition('bedrock'), - }, + ...getBedrockCredentialSubBlocks(options.bedrockSavedCredential ?? false), ] } @@ -643,6 +747,23 @@ export const PROVIDER_CREDENTIAL_INPUTS = { bedrockRegion: { type: 'string', description: 'AWS region for Bedrock' }, } as const +/** + * Input definitions for a saved AWS credential on Bedrock. Spread only by a + * block that also passes `bedrockSavedCredential` to + * {@link getProviderCredentialSubBlocks} and forwards the credential to its call. + */ +export const BEDROCK_SAVED_CREDENTIAL_INPUTS = { + bedrockAuthMode: { + type: 'string', + description: "How Bedrock authenticates: 'access_keys' (default) or 'credential'", + }, + bedrockCredential: { + type: 'string', + description: + "Saved AWS credential ID for Bedrock, used instead of access keys when bedrockAuthMode is 'credential'", + }, +} as const + /** * Create a versioned tool selector from an existing tool selector. * diff --git a/apps/sim/bootstrap.ts b/apps/sim/bootstrap.ts index be7987c8f54..5b2e7d6cee2 100644 --- a/apps/sim/bootstrap.ts +++ b/apps/sim/bootstrap.ts @@ -1,6 +1,8 @@ /** - * Container entrypoint. Hydrates `process.env` from the runtime secret, resolves an AWS - * Marketplace Enterprise license into `ENTERPRISE_ENABLED` on Marketplace installs, and drops + * Container entrypoint. Hydrates `process.env` from the runtime secret, refuses to start on a + * malformed `AWS_MARKETPLACE_PRODUCTS` (so a bad deploy rolls back instead of failing buyers and + * checkouts later), resolves an AWS Marketplace Enterprise license into `ENTERPRISE_ENABLED` on + * Marketplace installs, and drops * Next's route cache when an earlier build wrote it, before loading the Next.js standalone server, * so application modules that read env at import time see the full configuration. See * `@sim/runtime-secrets`. @@ -8,9 +10,13 @@ import { fileURLToPath } from 'node:url' import { loadRuntimeSecrets } from '@sim/runtime-secrets' import { applyAwsMarketplaceContainerLicense } from '@/lib/billing/aws-marketplace/container-license' +import { parseAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/products' import { clearRouteCacheFromOtherBuild } from '@/lib/core/utils/route-cache' await loadRuntimeSecrets() +if (process.env.AWS_MARKETPLACE_PRODUCTS) { + parseAwsMarketplaceProducts(process.env.AWS_MARKETPLACE_PRODUCTS) +} await applyAwsMarketplaceContainerLicense() await clearRouteCacheFromOtherBuild(fileURLToPath(new URL('./.next', import.meta.url))) // `server.js` is the Next standalone build artifact, a sibling of this file in diff --git a/apps/sim/components/computer-use/activity.test.tsx b/apps/sim/components/computer-use/activity.test.tsx index ebda9e89236..be79032b95f 100644 --- a/apps/sim/components/computer-use/activity.test.tsx +++ b/apps/sim/components/computer-use/activity.test.tsx @@ -1,12 +1,10 @@ /** @vitest-environment jsdom */ import { act } from 'react' import type { ComputerUseActivity as Activity, ComputerUseStatus } from '@sim/desktop-bridge' -import { createDeferred } from '@sim/testing/helpers/deferred' import { libDesktopMock, libDesktopMockFns } from '@sim/testing/mocks/lib-desktop.mock' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { ComputerUseActivity } from '@/components/computer-use/activity' -import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' const native = vi.hoisted(() => ({ getActivity: vi.fn<() => Promise>(), @@ -39,22 +37,7 @@ it.each(['pending', 'failed'] as const)( native.getStatus.mockImplementation(() => state === 'failed' ? Promise.reject(new Error('unavailable')) : new Promise(() => {}) ) - await act(async () => - root.render( - - - - ) - ) + await act(async () => root.render()) const listener = native.onActivity.mock.calls[0]?.[0] if (!listener) throw new Error('Activity listener not registered') await act(async () => @@ -74,48 +57,3 @@ it.each(['pending', 'failed'] as const)( expect(container.querySelector('button')).toBeNull() } ) - -it.each(['active', 'completed'] as const)( - 'loads an existing %s action without waking the helper when rollout is off', - async (state) => { - const snapshot = createDeferred() - native.getActivity.mockReturnValue(snapshot.promise) - await act(async () => - root.render( - - - - ) - ) - if (state === 'completed') { - const listener = native.onActivity.mock.calls[0]?.[0] - if (!listener) throw new Error('Activity listener not registered') - await act(async () => listener(null)) - } - await act(async () => - snapshot.resolve({ - toolCallId: 'existing', - scopeId: 'chat', - action: 'click', - startedAt: Date.now(), - }) - ) - expect(native.getStatus).not.toHaveBeenCalled() - const stop = container.querySelector('button') - if (state === 'completed') expect(stop).toBeNull() - else { - expect(stop?.textContent).toContain('Stop') - await act(async () => stop?.click()) - expect(native.cancel).toHaveBeenCalledOnce() - } - } -) diff --git a/apps/sim/components/computer-use/activity.tsx b/apps/sim/components/computer-use/activity.tsx index 44502d4c9f9..384d8d0764f 100644 --- a/apps/sim/components/computer-use/activity.tsx +++ b/apps/sim/components/computer-use/activity.tsx @@ -19,7 +19,7 @@ const ACTION_LABELS: Record = { perform_action: 'Interacting', } -/** Remains visible while a native action is active, even if rollout or device access is revoked. */ +/** Remains visible while a native action is active, even if the permission group or device access revokes it. */ export function ComputerUseActivity() { const { activeAction: activity } = useComputerUseStatus() if (!activity) return null diff --git a/apps/sim/connectors/auth.test.ts b/apps/sim/connectors/auth.test.ts index ad6d02739d4..3349e3b56b7 100644 --- a/apps/sim/connectors/auth.test.ts +++ b/apps/sim/connectors/auth.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vitest' -import { isConnectorCredentialTypeAllowed } from '@/connectors/auth' +import { + connectorHasAuthSource, + connectorUsesCredential, + getConnectorCredentialService, + isConnectorCredentialTypeAllowed, +} from '@/connectors/auth' import { confluenceConnectorMeta } from '@/connectors/confluence/meta' +import { databricksConnectorMeta } from '@/connectors/databricks/meta' +import { githubConnectorMeta } from '@/connectors/github/meta' import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta' +import { s3ConnectorMeta } from '@/connectors/s3/meta' describe('connector credential eligibility', () => { it.each([confluenceConnectorMeta, googleDriveConnectorMeta])( @@ -24,3 +32,47 @@ describe('connector credential eligibility', () => { } ) }) + +describe('connector auth source', () => { + const s3Auth = s3ConnectorMeta.auth + const keyOnlyAuth = databricksConnectorMeta.auth + + it('keeps a stored-key S3 connector on its key', () => { + const row = { credentialId: null, encryptedApiKey: 'encrypted-key' } + expect(connectorUsesCredential(s3Auth, row)).toBe(false) + expect(connectorHasAuthSource(s3Auth, row)).toBe(true) + }) + + it('authenticates an S3 connector that names a saved credential with that credential', () => { + const row = { credentialId: 'credential-1', encryptedApiKey: null } + expect(connectorUsesCredential(s3Auth, row)).toBe(true) + expect(connectorHasAuthSource(s3Auth, row)).toBe(true) + }) + + it('treats an S3 connector whose credential was removed as needing reconnection', () => { + expect(connectorHasAuthSource(s3Auth, { credentialId: null, encryptedApiKey: null })).toBe( + false + ) + }) + + it('never routes a key-only connector to a credential it does not accept', () => { + const row = { credentialId: 'credential-1', encryptedApiKey: 'encrypted-key' } + expect(connectorUsesCredential(keyOnlyAuth, row)).toBe(false) + expect(getConnectorCredentialService(keyOnlyAuth)).toBeUndefined() + }) + + it('keeps an OAuth connector with a token alternative on the token it stored', () => { + expect( + connectorUsesCredential(githubConnectorMeta.auth, { + credentialId: null, + encryptedApiKey: 'encrypted-token', + }) + ).toBe(false) + expect( + connectorUsesCredential(githubConnectorMeta.auth, { + credentialId: 'credential-1', + encryptedApiKey: null, + }) + ).toBe(true) + }) +}) diff --git a/apps/sim/connectors/auth.ts b/apps/sim/connectors/auth.ts index adf4dc9e4aa..8d74d5a8b59 100644 --- a/apps/sim/connectors/auth.ts +++ b/apps/sim/connectors/auth.ts @@ -1,4 +1,5 @@ -import type { ConnectorAuthConfig } from '@/connectors/types' +import type { OAuthService } from '@/lib/oauth/types' +import type { ConnectorAuthConfig, ConnectorConfigField } from '@/connectors/types' /** The same selected-source requirements drive credential filtering, validation, and token minting. */ export function getConnectorRequiredScopes( @@ -25,6 +26,36 @@ export function isConnectorCredentialTypeAllowed( ) } +/** The service whose saved credentials can authenticate this connector, if any. */ +export function getConnectorCredentialService(auth: ConnectorAuthConfig): OAuthService | undefined { + return auth.mode === 'oauth' ? auth.provider : auth.savedCredential?.provider +} + +/** + * Whether a connector row authenticates with its saved credential rather than a stored key. + * + * An OAuth connector uses its credential unless it also takes a token and the row stores + * only that. A key connector uses a credential only when it declares a `savedCredential` + * alternative and the row names one; creation and access updates never bind a credential to + * a row that stores a key, so such a row keeps using its key. + */ +export function connectorUsesCredential( + auth: ConnectorAuthConfig, + connector: { credentialId: string | null; encryptedApiKey: string | null } +): boolean { + if (auth.mode === 'apiKey') return Boolean(auth.savedCredential && connector.credentialId) + return !(auth.apiKey && !connector.credentialId && connector.encryptedApiKey) +} + +/** How a connector row authenticates, as recorded in audit metadata. */ +export function connectorAuthModeLabel( + authMode: ConnectorAuthConfig['mode'], + usesCredential: boolean +): 'apiKey' | 'oauth' | 'savedCredential' { + if (!usesCredential) return 'apiKey' + return authMode === 'apiKey' ? 'savedCredential' : 'oauth' +} + /** * Whether a workspace-mode connector row still carries something to authenticate with. A * connector whose credential was removed keeps its documents but has no token source, so a @@ -34,9 +65,16 @@ export function connectorHasAuthSource( auth: ConnectorAuthConfig, connector: { credentialId: string | null; encryptedApiKey: string | null } ): boolean { - const apiKeyConfig = getConnectorApiKeyConfig(auth) - if (apiKeyConfig && connector.encryptedApiKey) return true - return auth.mode === 'apiKey' ? apiKeyConfig?.optional === true : Boolean(connector.credentialId) + if (connectorUsesCredential(auth, connector)) return Boolean(connector.credentialId) + return Boolean(connector.encryptedApiKey) || getConnectorApiKeyConfig(auth)?.optional === true +} + +/** Whether a config field applies to how the connector authenticates: key-only fields drop out with a saved credential. */ +export function isConnectorFieldForAuth( + field: Pick, + authenticatesWithKey: boolean +): boolean { + return authenticatesWithKey || !field.apiKeyOnly } /** Workspace token input supported by a connector, independent of its member OAuth method. */ diff --git a/apps/sim/connectors/aws-session.ts b/apps/sim/connectors/aws-session.ts new file mode 100644 index 00000000000..e0289d5b84f --- /dev/null +++ b/apps/sim/connectors/aws-session.ts @@ -0,0 +1,44 @@ +/** + * The AWS keys a saved AWS credential resolved to for one connector run. + * + * A saved AWS credential has no bearer token: the server resolves it to a key + * pair (and, for a role, an STS session token) for the knowledge base's own + * scope. The resolver seeds them into the run's `syncContext` under one slot, + * the same way it seeds a credential's bound site, so a connector reads them + * from one place and never from tool or user input. Server-only state: the + * slot is never persisted or logged. + */ +export interface ConnectorAwsSession { + accessKeyId: string + secretAccessKey: string + /** Present for STS sessions (assumed and keyless roles); absent for saved long-lived keys. */ + sessionToken?: string +} + +const AWS_SESSION_CONTEXT_KEY = 'credentialAwsSession' + +/** The `syncContext` entries that carry a resolved AWS session to a connector. */ +export function awsSessionSyncContext(session: ConnectorAwsSession): Record { + return { + [AWS_SESSION_CONTEXT_KEY]: { + accessKeyId: session.accessKeyId, + secretAccessKey: session.secretAccessKey, + ...(session.sessionToken ? { sessionToken: session.sessionToken } : {}), + }, + } +} + +/** The AWS session seeded into this run, or undefined when the connector signs with inline keys. */ +export function readAwsSession( + syncContext: Record | undefined +): ConnectorAwsSession | undefined { + const value = syncContext?.[AWS_SESSION_CONTEXT_KEY] + if (typeof value !== 'object' || value === null) return undefined + const { accessKeyId, secretAccessKey, sessionToken } = value as Record + if (typeof accessKeyId !== 'string' || !accessKeyId) return undefined + if (typeof secretAccessKey !== 'string' || !secretAccessKey) return undefined + if (sessionToken !== undefined && (typeof sessionToken !== 'string' || !sessionToken)) { + return undefined + } + return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) } +} diff --git a/apps/sim/connectors/s3/meta.ts b/apps/sim/connectors/s3/meta.ts index 49367964ba4..de40b65b44b 100644 --- a/apps/sim/connectors/s3/meta.ts +++ b/apps/sim/connectors/s3/meta.ts @@ -13,6 +13,7 @@ export const s3ConnectorMeta: ConnectorMeta = { mode: 'apiKey', label: 'Secret Access Key', placeholder: 'Enter your AWS Secret Access Key', + savedCredential: { provider: 'aws', label: 'Saved AWS credential' }, }, configFields: [ @@ -22,6 +23,7 @@ export const s3ConnectorMeta: ConnectorMeta = { type: 'short-input', placeholder: 'e.g. AKIAIOSFODNN7EXAMPLE', required: true, + apiKeyOnly: true, }, { id: 'region', @@ -30,7 +32,7 @@ export const s3ConnectorMeta: ConnectorMeta = { placeholder: 'e.g. us-east-1 (use auto for Cloudflare R2)', required: true, description: - 'AWS region for the bucket. For Cloudflare R2 use "auto"; for MinIO use the region the server is configured with (commonly us-east-1).', + 'AWS region for the bucket. For Cloudflare R2 use "auto"; for MinIO use the region the server is configured with (commonly us-east-1). A saved AWS credential needs a commercial or GovCloud AWS region.', }, { id: 'bucket', @@ -47,6 +49,7 @@ export const s3ConnectorMeta: ConnectorMeta = { required: false, description: 'S3-compatible endpoint for Cloudflare R2, MinIO, etc. Leave empty for AWS S3. Uses path-style addressing. Plain http:// is only allowed for localhost.', + apiKeyOnly: true, }, { id: 'prefix', diff --git a/apps/sim/connectors/s3/s3.test.ts b/apps/sim/connectors/s3/s3.test.ts new file mode 100644 index 00000000000..6dc93080fb6 --- /dev/null +++ b/apps/sim/connectors/s3/s3.test.ts @@ -0,0 +1,167 @@ +/** + * Pins the S3 connector's two signing paths. Inline keys sign exactly as they + * always have; a saved AWS credential (seeded into `syncContext` by the token + * resolver) signs with its STS session token and only ever for Amazon S3 — a + * custom S3-compatible endpoint would otherwise receive the customer's AWS + * session. + */ +import { knowledgeDocumentsUtilsMock } from '@sim/testing/mocks/knowledge-documents-utils.mock' +import { + knowledgeSecureFetchMock, + knowledgeSecureFetchMockFns, +} from '@sim/testing/mocks/knowledge-secure-fetch.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/knowledge/documents/utils', () => knowledgeDocumentsUtilsMock) +vi.mock('@/lib/knowledge/documents/secure-fetch.server', () => knowledgeSecureFetchMock) + +import { awsSessionSyncContext } from '@/connectors/aws-session' +import { s3Connector } from '@/connectors/s3/s3' + +const mockSecureFetchWithRetry = knowledgeSecureFetchMockFns.mockSecureFetchWithRetry + +const SESSION = { + accessKeyId: 'ASIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + sessionToken: 'IQoJb3JpZ2luX2VjEXAMPLE+token/with=chars', +} + +const EMPTY_LISTING = + 'false' + +function recordRequests() { + const requests: { url: string; headers: Record }[] = [] + mockSecureFetchWithRetry.mockImplementation( + async (url: string, init: { headers: Record }) => { + requests.push({ url, headers: init.headers }) + return new Response(EMPTY_LISTING, { status: 200 }) + } + ) + return requests +} + +describe('s3Connector signing', () => { + beforeEach(() => { + mockSecureFetchWithRetry.mockReset() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-11-06T12:00:00Z')) + }) + + it('signs a saved credential session token into the request, as SigV4 requires', async () => { + const requests = recordRequests() + + await s3Connector.listDocuments( + 'credential-handle', + { region: 'us-east-1', bucket: 'docs-bucket', prefix: 'kb/' }, + undefined, + { ...awsSessionSyncContext(SESSION) } + ) + + expect(requests).toHaveLength(1) + const [request] = requests + expect(request.url).toBe( + 'https://docs-bucket.s3.us-east-1.amazonaws.com/?encoding-type=url&list-type=2&max-keys=1000&prefix=kb%2F' + ) + expect(request.headers['X-Amz-Security-Token']).toBe(SESSION.sessionToken) + // Reference value from @smithy/signature-v4 (the AWS SDK signer) for the same request. + expect(request.headers.Authorization).toBe( + 'AWS4-HMAC-SHA256 Credential=ASIAIOSFODNN7EXAMPLE/20261106/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=dfe6577a8c0c32916977121ba18620aeca7758bf39d0230a02d29183ff49677a' + ) + }) + + it('never sends a saved AWS credential to a custom endpoint', async () => { + recordRequests() + + const validation = await s3Connector.validateConfig( + 'credential-handle', + { region: 'us-east-1', bucket: 'docs', endpoint: 'https://attacker.example.com' }, + { ...awsSessionSyncContext(SESSION) } + ) + + expect(validation.valid).toBe(false) + expect(validation.error).toMatch(/custom endpoint/i) + expect(mockSecureFetchWithRetry).not.toHaveBeenCalled() + }) + + it('refuses a non-AWS region with a saved AWS credential', async () => { + recordRequests() + + const validation = await s3Connector.validateConfig( + 'credential-handle', + { accessKeyId: 'AKIAIOSFODNN7EXAMPLE', region: 'auto', bucket: 'docs' }, + { ...awsSessionSyncContext(SESSION) } + ) + + expect(validation.valid).toBe(false) + expect(validation.error).toMatch(/AWS region/i) + expect(mockSecureFetchWithRetry).not.toHaveBeenCalled() + }) + + it.each(['cn-north-1', 'us-iso-east-1', 'eu-isoe-west-1'])( + 'refuses %s, whose S3 host is not under amazonaws.com, with a saved AWS credential', + async (region) => { + recordRequests() + + const validation = await s3Connector.validateConfig( + 'credential-handle', + { region, bucket: 'docs' }, + { ...awsSessionSyncContext(SESSION) } + ) + + expect(validation.valid).toBe(false) + expect(validation.error).toMatch(/commercial or GovCloud/i) + expect(mockSecureFetchWithRetry).not.toHaveBeenCalled() + } + ) + + it('signs a saved AWS credential for a GovCloud bucket', async () => { + const requests = recordRequests() + + await s3Connector.listDocuments( + '', + { region: 'us-gov-west-1', bucket: 'docs-bucket' }, + undefined, + { ...awsSessionSyncContext(SESSION) } + ) + + expect(requests[0]?.url).toMatch(/^https:\/\/docs-bucket\.s3\.us-gov-west-1\.amazonaws\.com\//) + }) + + it('keeps signing inline keys without a session token', async () => { + const requests = recordRequests() + + await s3Connector.listDocuments( + SESSION.secretAccessKey, + { + accessKeyId: SESSION.accessKeyId, + region: 'us-east-1', + bucket: 'docs-bucket', + prefix: 'kb/', + }, + undefined, + {} + ) + + const [request] = requests + expect(request.headers['X-Amz-Security-Token']).toBeUndefined() + // Reference value from @smithy/signature-v4 for the same request without a session token. + expect(request.headers.Authorization).toBe( + 'AWS4-HMAC-SHA256 Credential=ASIAIOSFODNN7EXAMPLE/20261106/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=bceba1b42847dc164dfe175c63bc088449c244e6e91cce4ecd6d9b06f6508b0d' + ) + }) + + it('ignores an inline access key ID once a saved credential signs', async () => { + const requests = recordRequests() + + await s3Connector.getDocument( + 'credential-handle', + { accessKeyId: 'AKIAIOSFODNN7EXAMPLE', region: 'us-east-1', bucket: 'docs-bucket' }, + 'notes/readme.md', + { ...awsSessionSyncContext(SESSION) } + ) + + expect(requests[0].headers.Authorization).toContain( + `Credential=${SESSION.accessKeyId}/20261106/us-east-1/s3/aws4_request` + ) + }) +}) diff --git a/apps/sim/connectors/s3/s3.ts b/apps/sim/connectors/s3/s3.ts index 5e07d171339..d9686d10353 100644 --- a/apps/sim/connectors/s3/s3.ts +++ b/apps/sim/connectors/s3/s3.ts @@ -7,6 +7,7 @@ import { isHosted } from '@/lib/core/config/env-flags' import { decodeTextBuffer } from '@/lib/file-parsers/utils' import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { readAwsSession } from '@/connectors/aws-session' import { s3ConnectorMeta } from '@/connectors/s3/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { @@ -52,6 +53,13 @@ const BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{1,62}$/ /** Accepted region shape — AWS region codes plus Cloudflare R2's `auto`. */ const REGION_PATTERN = /^[A-Za-z0-9-]{1,64}$/ +/** + * A commercial or GovCloud AWS region code (`us-east-1`, `us-gov-west-1`): the only regions a + * saved AWS credential signs for, because their S3 hosts are all `s3..amazonaws.com`. + * China (`cn-*`, under `amazonaws.com.cn`) and the isolated partitions use other domains. + */ +const AWS_REGION_PATTERN = /^(?!cn-)[a-z]{2}(-gov)?-[a-z]+-\d+$/ + /** * Default set of file extensions considered safely text-extractable. Objects * with any other extension (or no extension) are skipped, since their content @@ -115,6 +123,8 @@ interface S3Endpoint { interface S3Context { accessKeyId: string secretAccessKey: string + /** The STS session token of a saved AWS credential's role session; signed into every request. */ + sessionToken?: string region: string bucket: string endpoint?: S3Endpoint @@ -206,20 +216,29 @@ function parseEndpoint(raw: string): S3Endpoint { } /** - * Resolves AWS credentials and the target bucket from the connector's - * sourceConfig and the encrypted secret (delivered as accessToken). When an - * `endpoint` is configured it is parsed/validated into an {@link S3Endpoint} so - * the connector targets an S3-compatible store via path-style addressing. + * Resolves AWS credentials and the target bucket. + * + * With a saved AWS credential, the server seeds its resolved keys into + * `syncContext` and those sign every request; the inline `accessKeyId` and the + * access token are not read. A saved credential signs only for Amazon S3 in an + * AWS region: a custom endpoint would hand the customer's AWS session to + * whatever host it names, so it is refused rather than ignored. + * + * Otherwise the inline key pair comes from `sourceConfig.accessKeyId` and the + * encrypted secret (delivered as accessToken), and an `endpoint` is parsed into + * an {@link S3Endpoint} so the connector targets an S3-compatible store via + * path-style addressing. */ -function resolveContext(accessToken: string, sourceConfig: Record): S3Context { - const accessKeyId = ((sourceConfig.accessKeyId as string) ?? '').trim() +function resolveContext( + accessToken: string, + sourceConfig: Record, + syncContext: Record | undefined +): S3Context { const region = ((sourceConfig.region as string) ?? '').trim() const bucket = ((sourceConfig.bucket as string) ?? '').trim() - const secretAccessKey = (accessToken ?? '').trim() const rawEndpoint = ((sourceConfig.endpoint as string) ?? '').trim() + const savedSession = readAwsSession(syncContext) - if (!accessKeyId) throw new Error('Missing AWS Access Key ID') - if (!secretAccessKey) throw new Error('Missing AWS Secret Access Key') if (!region) throw new Error('Missing AWS region') if (!bucket) throw new Error('Missing S3 bucket name') if (!REGION_PATTERN.test(region)) { @@ -231,6 +250,25 @@ function resolveContext(accessToken: string, sourceConfig: Record ): Promise => { - const ctx = resolveContext(accessToken, sourceConfig) + const ctx = resolveContext(accessToken, sourceConfig, syncContext) const prefix = ((sourceConfig.prefix as string) ?? '').trim() const allowedExtensions = resolveExtensions(sourceConfig.extensions) @@ -602,9 +649,10 @@ export const s3Connector: ConnectorConfig = { getDocument: async ( accessToken: string, sourceConfig: Record, - externalId: string + externalId: string, + syncContext?: Record ): Promise => { - const ctx = resolveContext(accessToken, sourceConfig) + const ctx = resolveContext(accessToken, sourceConfig, syncContext) const key = externalId if (hasDotSegment(key)) { @@ -680,11 +728,12 @@ export const s3Connector: ConnectorConfig = { validateConfig: async ( accessToken: string, - sourceConfig: Record + sourceConfig: Record, + syncContext?: Record ): Promise<{ valid: boolean; error?: string }> => { let ctx: S3Context try { - ctx = resolveContext(accessToken, sourceConfig) + ctx = resolveContext(accessToken, sourceConfig, syncContext) } catch (error) { return { valid: false, error: getErrorMessage(error, 'Invalid configuration') } } diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index 92dc23b7e44..22cb3c18dc1 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -60,6 +60,16 @@ export type ConnectorAuthConfig = * connector receives an empty access token. */ optional?: boolean + /** + * A saved credential that may authenticate a workspace crawl instead of + * the key, such as a saved AWS credential for Amazon S3. Additive: a + * connector row with a stored key keeps using it, and one that names a + * credential resolves it for the knowledge base's own scope. Fields + * marked {@link ConnectorConfigField.apiKeyOnly} describe the key and + * do not apply to a credential. Only AWS resolves to signing keys today; + * another provider needs its own resolution in the connector access token. + */ + savedCredential?: { provider: 'aws'; label: string } } /** A group in an external directory, named as the connector's ACLs name it. */ @@ -335,6 +345,12 @@ export interface ConnectorConfigField { hideInAdminMode?: true /** Only applies to a central crawl that mirrors source permissions. */ showInAdminModeOnly?: true + /** + * Describes the connector's own API key (an access key ID, a custom host the + * key signs for), so it is hidden, not required, and not sent when the + * connector authenticates with its `savedCredential` instead. + */ + apiKeyOnly?: true options?: { label: string; id: string }[] /** Selector key from the selector registry (used when type is 'selector') */ diff --git a/apps/sim/content/changelog/saved-credentials-and-access/index.mdx b/apps/sim/content/changelog/saved-credentials-and-access/index.mdx new file mode 100644 index 00000000000..b47a843521d --- /dev/null +++ b/apps/sim/content/changelog/saved-credentials-and-access/index.mdx @@ -0,0 +1,38 @@ +--- +slug: saved-credentials-and-access +title: 'Connect once and control how credentials are used' +description: 'Sim workflows can reuse saved credentials instead of repeated keys. Keyless cloud connections and read-only API keys give teams more control.' +date: '2026-11-10T20:00:00Z' +updated: '2026-11-12T05:00:30Z' +authors: [waleed] +tags: [Workflows, Security] +ogImage: /changelog/credentials-gateway-v1.jpg +ogAlt: 'Monochrome illustration of two stone gateways overlooking mountain ridges' +technical: false +draft: false +release: + versions: [v0.9.53, v0.9.54] +--- + + + +Save a connection once and select it in your workflow blocks. Keys and connection details stay in one place instead of being copied into every block, and you choose who can use the saved credential. + +Introduced in v0.9.53 and expanded in v0.9.54, saved credentials cover more services that previously asked for keys directly. Existing blocks keep their original settings. To use a saved connection, replace a legacy block with its current version and select the credential. + +### Connect without a long-lived key + +In v0.9.53, supported cloud services can trust Sim to obtain temporary access. Your cloud administrator configures the trust and permissions; self-hosted deployments also need workload identity configured. See the [AWS](https://docs.sim.ai/integrations/aws-credentials) and [Databricks](https://docs.sim.ai/integrations/databricks-credentials) guides. + +### Give scripts only the access they need + +Create a **Read only** API key with an expiration date under **Sim API keys** in account or workspace settings. Introduced in v0.9.53 and generally available in v0.9.54, these keys can inspect information without changing workspace resources or starting, resuming, or cancelling workflows. + +Allowed reads can still consume credits or call connected services. Existing keys retain full access. [API key access and expiration](https://docs.sim.ai/api-reference/authentication#key-access). + +Technical releases: [v0.9.53](https://github.com/simstudioai/sim/releases/tag/v0.9.53), [v0.9.54](https://github.com/simstudioai/sim/releases/tag/v0.9.54). diff --git a/apps/sim/content/changelog/saved-credentials-and-access/production.md b/apps/sim/content/changelog/saved-credentials-and-access/production.md new file mode 100644 index 00000000000..618f0a9918d --- /dev/null +++ b/apps/sim/content/changelog/saved-credentials-and-access/production.md @@ -0,0 +1,49 @@ +# Connect once and control how credentials are used + +Edition identity: `manual-saved-credentials-and-access`. Canonical: https://www.sim.ai/changelog/saved-credentials-and-access. + +## Date and scope + +Retrospective edition dated 2026-11-10, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged. + +Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused. + +Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding. + +## Sources + +| Source | Change reviewed | Published release | +| --- | --- | --- | +| [#9763](https://github.com/simstudioai/sim/pull/9763) | feat(credentials): saved credentials and credential-only block versions, with keyless AWS/Databricks OIDC | v0.9.53 | +| [#9840](https://github.com/simstudioai/sim/pull/9840) | feat(credentials): saved credentials for 17 more blocks, GitLab tokens, and keyless hardening | v0.9.53 | +| [#9849](https://github.com/simstudioai/sim/pull/9849) | feat(credentials): saved credentials for 21 token-and-host blocks on one host-aware check factory | v0.9.53 | +| [#9869](https://github.com/simstudioai/sim/pull/9869) | feat(credentials): keyless Snowflake, Microsoft app-only and Google keyless, GitHub and Bitbucket tokens, DocuSign JWT | v0.9.53 | +| [#9875](https://github.com/simstudioai/sim/pull/9875) | feat(credentials): set up keyless credentials from the v2 API, CLI and Sim MCP | v0.9.53 | +| [#9855](https://github.com/simstudioai/sim/pull/9855) | feat(api-keys): read-only API keys and expiry at creation | v0.9.53 | +| [#9879](https://github.com/simstudioai/sim/pull/9879) | chore(api-keys): remove the api-key-read-only flag | v0.9.54 | +| [#9911](https://github.com/simstudioai/sim/pull/9911) | feat(credentials): audit fixes and non-OAuth auth for CrowdStrike, Datadog, Gong, PlanetScale, SailPoint, ServiceNow, Zendesk, ZoomInfo, Shopify, Atlassian, Linear and Twilio | v0.9.54 | + +Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused. + +## Media + +Original generated editorial illustration, 1672 × 941. Prompt: “Use case: stylized-concept. Create an original editorial illustration for a Sim changelog article about reusable credentials and carefully controlled access. Wide 16:9 composition. A quiet monumental pale concrete gateway with two offset rectangular openings, viewed obliquely across a rocky plateau, distant mountain ridges visible through the opening. Architectural clarity, soft cloudy daylight, painterly graphite and gouache texture, strictly grayscale, restrained mid-gray contrast, fine natural texture. Sophisticated calm landscape like a hand-painted architectural study. No people, no locks or keys as icons, no text, no logos, no interface, no color, no borders. Single complete image.” + +This is conceptual art, not product UI or evidence of an execution. Independently verified behavior comes from the source changes and live product/docs. + +Final immutable asset: `/changelog/credentials-gateway-v1.jpg`. The shared media frame, radius and lightbox are unchanged. Original files and generation provenance are preserved privately. No credentials, private workspace content, performance claims or generated product controls appear in the asset. + +## Review + +- [x] Claim/source/release and access review completed. +- [x] Media inspected at native dimensions. +- [x] Revised article desktop/mobile layout, links, metadata and discovery checked. +- [x] Required local repository gates completed; test suites are left to CI. +- [ ] Both current-head reviews and CI completed. +- [ ] Actual staging deployment and hosted QA completed. + +## Announcement draft + +Sim workflows can reuse saved credentials instead of repeated keys. Keyless cloud connections and read-only API keys give teams more control. https://www.sim.ai/changelog/saved-credentials-and-access + +Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication. diff --git a/apps/sim/content/changelog/search-across-sources/index.mdx b/apps/sim/content/changelog/search-across-sources/index.mdx index c9a7d5a42a2..7d74dc93bd5 100644 --- a/apps/sim/content/changelog/search-across-sources/index.mdx +++ b/apps/sim/content/changelog/search-across-sources/index.mdx @@ -1,8 +1,9 @@ --- slug: search-across-sources title: 'More relevant results across your connected sources' -description: 'Sim Search surfaces closer matches across connected sources. Also: sign out other devices and edit more of your Excel workbooks.' +description: 'Sim Search surfaces closer matches across connected sources. This update also expands editing for Microsoft Excel workbooks.' date: '2026-10-19T16:00:00Z' +updated: '2026-11-12T03:30:00Z' authors: [waleed] tags: [Search, Workspace] ogImage: /changelog/search-clear-path-v1.jpg @@ -26,7 +27,7 @@ Introduced in v0.9.25, this applies to keyword searches sorted by relevance in S ### Also in v0.9.26 -- **Sign out other devices** in Settings → General ends your other sessions while keeping your current session and connected apps; sign-out can take up to five minutes. +- **Sign out other devices** was added to Settings → General in this release. The control was later removed in v0.9.42 and is no longer available there. ### Also in v0.9.27 diff --git a/apps/sim/content/changelog/search-across-sources/production.md b/apps/sim/content/changelog/search-across-sources/production.md index 38f94340ec4..3330fefbc1f 100644 --- a/apps/sim/content/changelog/search-across-sources/production.md +++ b/apps/sim/content/changelog/search-across-sources/production.md @@ -52,3 +52,7 @@ Final asset: `/changelog/search-clear-path-v1.jpg`, 1672 × 941, optimized JPEG **Email:** Find more relevant results across the sources you use in Sim. Keyword searches compare matches across apps, including short terms such as AI and VM. You can also sign out other devices from General settings and use expanded Excel editing with a Microsoft business account. Read the update: https://www.sim.ai/changelog/search-across-sources Do not distribute before the production article is verified. The article carries the full access and reconnection requirements. + +## November 11 historical correction + +Source #9595, explicitly released in v0.9.42, removed Sign out other devices from General settings. The article now states that current availability, with a substantive updated date. Its original publication date, slug, media and canonical RSS identity are preserved. The original v0.9.26 release claim remains historical rather than presenting a removed control as available. diff --git a/apps/sim/content/changelog/search-actions-and-browser/index.mdx b/apps/sim/content/changelog/search-actions-and-browser/index.mdx new file mode 100644 index 00000000000..d6a567e4059 --- /dev/null +++ b/apps/sim/content/changelog/search-actions-and-browser/index.mdx @@ -0,0 +1,34 @@ +--- +slug: search-actions-and-browser +title: 'Find answers and take action without leaving Search' +description: 'Sim Search turns findings into reviewed actions. Connect multiple accounts, cite web sources, and share useful destinations with short team links.' +date: '2026-10-30T20:00:00Z' +updated: '2026-11-12T05:00:30Z' +authors: [waleed] +tags: [Search, Workspace] +ogImage: /changelog/search-action-review-v1.jpg +ogAlt: 'Search Assistant showing two Jira issue proposals with Edit, Allow, and Cancel controls' +technical: false +draft: false +release: + versions: [v0.9.37, v0.9.43, v0.9.45] +--- + + + +Find the information you need, then act on it in the same conversation. Search Assistant can draft an email or create a Jira issue using your connected account. Writes ask for review by default: check the destination and content, edit supported fields, then allow or cancel. + +Introduced in v0.9.37, actions follow your account permissions and organization’s source restrictions. Set **Allow**, **Ask**, or **Block** for supported tools under Integrations. Your organization must enable Search and the relevant apps. [Review actions](https://docs.sim.ai/search/ask-questions#review-actions). + +### More ways to find and share + +- **v0.9.43 — Web citations:** When enabled, Assistant can fill gaps with public web results and cite them alongside your connected sources. +- **v0.9.43 — Team shortcuts:** Create or copy a short `sim/` link from a Search result, Assistant citation, or file’s sharing dialog when Links is enabled for your organization. [Share a team link](https://docs.sim.ai/search/sim-links). +- **v0.9.45 — Multiple accounts:** Search across up to five accounts per supported app without disconnecting one to use another; some providers still allow one. [Connect accounts](https://docs.sim.ai/search/connect-your-account). + +Technical releases: [v0.9.37](https://github.com/simstudioai/sim/releases/tag/v0.9.37), [v0.9.43](https://github.com/simstudioai/sim/releases/tag/v0.9.43), [v0.9.45](https://github.com/simstudioai/sim/releases/tag/v0.9.45). diff --git a/apps/sim/content/changelog/search-actions-and-browser/production.md b/apps/sim/content/changelog/search-actions-and-browser/production.md new file mode 100644 index 00000000000..b3fa662dc57 --- /dev/null +++ b/apps/sim/content/changelog/search-actions-and-browser/production.md @@ -0,0 +1,44 @@ +# Find answers and take action without leaving Search + +Edition identity: `manual-search-actions-and-browser`. Canonical: https://www.sim.ai/changelog/search-actions-and-browser. + +## Date and scope + +Retrospective edition dated 2026-10-30, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged. + +Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused. + +Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding. + +## Sources + +| Source | Change reviewed | Published release | +| --- | --- | --- | +| [#9428](https://github.com/simstudioai/sim/pull/9428) | feat(search): unify organization search and reviewed assistant actions | v0.9.37 | +| [#9451](https://github.com/simstudioai/sim/pull/9451) | fix(search): inline action review and unify account menus | v0.9.37 | +| [#9647](https://github.com/simstudioai/sim/pull/9647) | improvement(search): cite public web results like connected sources in Search Assistant | v0.9.43 | +| [#9648](https://github.com/simstudioai/sim/pull/9648) | feat(sim-links): create or copy a Sim link from search results and file shares | v0.9.43 | +| [#9716](https://github.com/simstudioai/sim/pull/9716) | feat(search): connect multiple accounts per integration | v0.9.45 | + +Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused. + +## Media + +Existing real product capture from the Search action-review documentation introduced with #9428, using sample data. Reused from `apps/docs/public/static/search/assistant-action-review.png`, 1440 × 1000, with aspect ratio preserved. It shows review proposals, not successful Jira delivery. The original feature QA used controlled provider transports; no new provider write was performed for this article. + +Final immutable asset: `/changelog/search-action-review-v1.jpg`. The shared media frame, radius and lightbox are unchanged. Original files and generation provenance are preserved privately. No credentials, private workspace content, performance claims or generated product controls appear in the asset. + +## Review + +- [x] Claim/source/release and access review completed. +- [x] Media inspected at native dimensions. +- [x] Revised article desktop/mobile layout, links, metadata and discovery checked. +- [x] Required local repository gates completed; test suites are left to CI. +- [ ] Both current-head reviews and CI completed. +- [ ] Actual staging deployment and hosted QA completed. + +## Announcement draft + +Sim Search turns findings into reviewed actions. Connect multiple accounts, cite web sources, and share useful destinations with short team links. https://www.sim.ai/changelog/search-actions-and-browser + +Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication. diff --git a/apps/sim/content/changelog/sim-in-your-browser/index.mdx b/apps/sim/content/changelog/sim-in-your-browser/index.mdx new file mode 100644 index 00000000000..628e6068e41 --- /dev/null +++ b/apps/sim/content/changelog/sim-in-your-browser/index.mdx @@ -0,0 +1,40 @@ +--- +slug: sim-in-your-browser +title: 'Ask Sim about the page you’re reading' +description: 'Bring pages and selected passages into Sim from Chrome. Add context from several tabs, dictate a draft, and open your team’s shared shortcuts.' +date: '2026-11-04T20:00:00Z' +updated: '2026-11-12T05:00:30Z' +authors: [waleed] +tags: [Chat, Workspace] +ogImage: /changelog/browser-open-window-v1.jpg +ogAlt: 'Monochrome illustration of a stone observatory opening onto a misty mountain valley' +technical: false +draft: false +release: + versions: [v0.9.37, v0.9.39, v0.9.44] +--- + + + +Ask a question about the page in front of you without copying it into another tab. The [Sim extension for Chrome](https://docs.sim.ai/browser-extension), introduced in v0.9.37 and expanded in v0.9.44, brings page text or a selected passage into your conversation. + +Add another page for context, dictate a question, and edit the draft before sending. You control which pages are attached. Chat requires a paid Sim plan or available prepaid credits. + +With Links enabled for your organization, type a shared `go/` or `sim/` shortcut in Chrome’s address bar to open a team destination. + +### Also in Sim Desktop + +These improvements shipped in v0.9.39: + +- **Finish passkey sign-in:** On macOS, continue a website’s sign-in in an installed Chrome-family browser, then bring its session back to Sim. +- **Familiar browser controls:** Use keyboard shortcuts to switch tabs and move back or forward, save or copy images from their menu, and watch fullscreen pages inside the browser panel. +- **Update when you’re ready:** Restart to install a downloaded update in one click when idle, with a warning if work could be interrupted; your app windows reopen afterward. + +[Download Sim Desktop and see the setup guide](https://docs.sim.ai/desktop). + +Technical releases: [v0.9.37](https://github.com/simstudioai/sim/releases/tag/v0.9.37), [v0.9.39](https://github.com/simstudioai/sim/releases/tag/v0.9.39), [v0.9.44](https://github.com/simstudioai/sim/releases/tag/v0.9.44). diff --git a/apps/sim/content/changelog/sim-in-your-browser/production.md b/apps/sim/content/changelog/sim-in-your-browser/production.md new file mode 100644 index 00000000000..b8afa3b980b --- /dev/null +++ b/apps/sim/content/changelog/sim-in-your-browser/production.md @@ -0,0 +1,46 @@ +# Ask Sim about the page you’re reading + +Edition identity: `manual-sim-in-your-browser`. Canonical: https://www.sim.ai/changelog/sim-in-your-browser. + +## Date and scope + +Retrospective edition dated 2026-11-04, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged. + +Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused. + +Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding. + +## Sources + +| Source | Change reviewed | Published release | +| --- | --- | --- | +| [#9454](https://github.com/simstudioai/sim/pull/9454) | feat(extension): bring Sim chat and page context to Chrome | v0.9.37 | +| [#9515](https://github.com/simstudioai/sim/pull/9515) | feat(desktop): continue a passkey sign-in in your browser and bring the session back | v0.9.39 | +| [#9512](https://github.com/simstudioai/sim/pull/9512) | improvement(desktop): bring agent browser menus, keys and fullscreen in line with Chrome | v0.9.39 | +| [#9505](https://github.com/simstudioai/sim/pull/9505) | improvement(desktop): install updates on one click, asking only about real in-flight work | v0.9.39 | +| [#9533](https://github.com/simstudioai/sim/pull/9533) | fix(desktop): ask before an update restart from the menu, count uploads and downloads as running work, and harden sign-in prompts | v0.9.39 | +| [#9547](https://github.com/simstudioai/sim/pull/9547) | fix(desktop): address release review — count app downloads and unchecked terminals before an update restart, send the page referrer on Save Image, and tighten sign-in and people edge cases | v0.9.39 | +| [#9683](https://github.com/simstudioai/sim/pull/9683) | improvement(extension): simplify page access and enable voice input | v0.9.44 | +| [#9689](https://github.com/simstudioai/sim/pull/9689) | improvement(extension): allow more pages within a shared content budget | v0.9.44 | +| [#9698](https://github.com/simstudioai/sim/pull/9698) | improvement(links): make go/ and sim/ work with no setup | v0.9.44 | + +Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused. + +## Media + +Original generated editorial illustration of an open stone observatory and mountain valley, 1672 × 941. Built-in image generation; no product UI, text, logos or invented results. The artwork expresses taking a workspace beyond a window and does not prove extension or desktop behavior. The full prompt and original output are retained with the art provenance. Final asset: `/changelog/browser-open-window-v1.jpg`. Shared media frame and lightbox unchanged. + +## Review + +- [x] Claim/source/release and access review completed. +- [x] Media inspected at native dimensions. +- [x] Revised article desktop/mobile layout, links, metadata and discovery checked. +- [x] Required local repository gates completed; test suites are left to CI. +- [ ] Both current-head reviews and CI completed. +- [ ] Actual staging deployment and hosted QA completed. + +## Announcement draft + +Bring pages and selected passages into Sim from Chrome. Add context from several tabs, dictate a draft, and open your team’s shared shortcuts. https://www.sim.ai/changelog/sim-in-your-browser + +Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication. diff --git a/apps/sim/content/changelog/video-from-your-image/index.mdx b/apps/sim/content/changelog/video-from-your-image/index.mdx new file mode 100644 index 00000000000..b44601a77e8 --- /dev/null +++ b/apps/sim/content/changelog/video-from-your-image/index.mdx @@ -0,0 +1,35 @@ +--- +slug: video-from-your-image +title: 'Start a video from your own image' +description: 'Sim can use your image as the starting frame for supported fal.ai videos, with more image and video model choices in workflow generators.' +date: '2026-11-07T20:00:00Z' +updated: '2026-11-12T05:00:30Z' +authors: [waleed] +tags: [Workflows, Media] +ogImage: /changelog/video-still-water-v1.jpg +ogAlt: 'Monochrome illustration of a sailboat crossing still water beneath drifting mountain fog' +technical: false +draft: false +release: + versions: [v0.9.43, v0.9.45, v0.9.47, v0.9.48] +--- + + + +Start with an image, then describe what happens next. In v0.9.48, Video Generator can use an uploaded image or a file from an earlier step as the starting frame for supported fal.ai models. + +Choose a compatible model to reveal **Reference Image**. Duration, resolution, aspect ratio, and audio options follow that model’s capabilities. Models without start-frame support remain text-to-video only. + +Use your own fal.ai key, or Sim’s hosted access with an eligible paid plan or prepaid credits. Generation is billed by usage. Send the result to another step or [save it to workspace Files](https://docs.sim.ai/files/generating#save-an-existing-file). + +### More creative options + +- **v0.9.43:** Image Generator and Video Generator expose each supported model’s settings, so you can choose the format and controls your task needs. +- **v0.9.47:** [Higgsfield](/integrations/higgsfield) joins both generators. Custom models accept a prompt only; custom fal.ai endpoints require your own key. [Higgsfield setup](https://docs.sim.ai/integrations/higgsfield). + +Technical releases: [v0.9.43](https://github.com/simstudioai/sim/releases/tag/v0.9.43), [v0.9.45](https://github.com/simstudioai/sim/releases/tag/v0.9.45), [v0.9.47](https://github.com/simstudioai/sim/releases/tag/v0.9.47), [v0.9.48](https://github.com/simstudioai/sim/releases/tag/v0.9.48). diff --git a/apps/sim/content/changelog/video-from-your-image/production.md b/apps/sim/content/changelog/video-from-your-image/production.md new file mode 100644 index 00000000000..a5917d8d222 --- /dev/null +++ b/apps/sim/content/changelog/video-from-your-image/production.md @@ -0,0 +1,47 @@ +# Start a video from your own image + +Edition identity: `2026-11-09`. Canonical: https://www.sim.ai/changelog/video-from-your-image. + +## Date and scope + +Retrospective edition dated 2026-11-07, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged. + +Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused. + +Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding. + +## Sources + +| Source | Change reviewed | Published release | +| --- | --- | --- | +| [#9761](https://github.com/simstudioai/sim/pull/9761) | feat(video): accept a start frame image for fal.ai video models | v0.9.48 | +| [#9787](https://github.com/simstudioai/sim/pull/9787) | fix(video): read the active start frame input when serializing video settings | v0.9.48 | +| [#9640](https://github.com/simstudioai/sim/pull/9640) | feat(video): expand the fal text-to-video model catalog | v0.9.43 | +| [#9653](https://github.com/simstudioai/sim/pull/9653) | feat(image-generator): refresh provider model catalogs and validate requests | v0.9.43 | +| [#9756](https://github.com/simstudioai/sim/pull/9756) | feat(media): add Higgsfield to video and image generators, allow typed models | v0.9.47 | +| [#9741](https://github.com/simstudioai/sim/pull/9741) | fix(billing): withhold hosted Fal.ai video generation from free-plan payers | v0.9.45 | + +Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused. + +## Media + +Original generated editorial illustration, 1672 × 941. Prompt: “Use case: stylized-concept. Original editorial illustration for a Sim changelog article about turning a still reference image into a video. Wide 16:9 composition. A quiet coastal inlet with a small sailboat, a broad luminous bank of fog drifting between distant layered mountains and low rocky islands, subtle ripple arcs across still water. Hand-painted graphite and gouache look with delicate paper grain, strictly grayscale, soft silver light, restrained contrast, cinematic wide framing, quiet motion suggested through water and cloud shapes. No words, no logos, no interface, no film frames or equipment, no decorative border. Single complete image.” + +This is conceptual art, not product UI or evidence of an execution. Independently verified behavior comes from the source changes and live product/docs. + +Final immutable asset: `/changelog/video-still-water-v1.jpg`. The shared media frame, radius and lightbox are unchanged. Original files and generation provenance are preserved privately. No credentials, private workspace content, performance claims or generated product controls appear in the asset. + +## Review + +- [x] Claim/source/release and access review completed. +- [x] Media inspected at native dimensions. +- [x] Revised article desktop/mobile layout, links, metadata and discovery checked. +- [x] Required local repository gates completed; test suites are left to CI. +- [ ] Both current-head reviews and CI completed. +- [ ] Actual staging deployment and hosted QA completed. + +## Announcement draft + +Sim can use your image as the starting frame for supported fal.ai videos, with more image and video model choices in workflow generators. https://www.sim.ai/changelog/video-from-your-image + +Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication. diff --git a/apps/sim/content/changelog/workflow-run-controls/index.mdx b/apps/sim/content/changelog/workflow-run-controls/index.mdx new file mode 100644 index 00000000000..e3ed5943306 --- /dev/null +++ b/apps/sim/content/changelog/workflow-run-controls/index.mdx @@ -0,0 +1,41 @@ +--- +slug: workflow-run-controls +title: 'See which workflows need your attention' +description: 'Sim makes paused runs and schedules easier to inspect from the CLI or API, and lets you run an earlier deployed version without changing the active one.' +date: '2026-11-11T20:00:00Z' +updated: '2026-11-12T05:00:30Z' +authors: [waleed] +tags: [Workflows, Tables] +ogImage: /changelog/workflow-paths-v1.jpg +ogAlt: 'Monochrome illustration of two roads meeting around a mountain ridge' +technical: false +draft: false +release: + versions: [v0.9.37, v0.9.47, v0.9.53, v0.9.54] +--- + + + +Find the work waiting on a person without opening every workflow. In v0.9.54, the [Sim CLI](https://docs.sim.ai/cli/workflows) and API can list paused runs across a workspace, including the review or input each run needs. Details follow your access permissions. + +You can also [list workspace schedules](https://docs.sim.ai/cli/schedules) to see their timing and health together. Change timing in the workflow and redeploy it. + +### Keep work moving + +- **v0.9.37 — Long runs:** Set a wait limit when starting a workflow through the CLI or API; if it’s still running, it continues in the background and you can check back later, within your plan’s execution limits. +- **v0.9.53 — Earlier versions:** Run a previous deployed version through the API without changing the active version; this requires Write access. [Run a deployed version](https://docs.sim.ai/workflows/deployment/api). +- **v0.9.53 — Trigger controls:** Inspect trigger health or disable a deployed trigger through the CLI or API; disabling lasts until the next deployment or version activation and leaves running work alone. + +### Work with files + +- **v0.9.47:** New CSV columns keep leading-zero ZIP codes and SKUs as text. +- **v0.9.53:** Mistral Parser V3 can extract images from an uploaded or referenced PDF as files for later steps, and return tables as Markdown or HTML. [Parser guide](https://docs.sim.ai/integrations/mistral_parse). + +The legacy `simstudio` CLI and Python and TypeScript SDKs were retired in v0.9.53. Use the current Sim CLI or HTTP API. + +Technical releases: [v0.9.37](https://github.com/simstudioai/sim/releases/tag/v0.9.37), [v0.9.47](https://github.com/simstudioai/sim/releases/tag/v0.9.47), [v0.9.53](https://github.com/simstudioai/sim/releases/tag/v0.9.53), [v0.9.54](https://github.com/simstudioai/sim/releases/tag/v0.9.54). diff --git a/apps/sim/content/changelog/workflow-run-controls/production.md b/apps/sim/content/changelog/workflow-run-controls/production.md new file mode 100644 index 00000000000..d305422b04a --- /dev/null +++ b/apps/sim/content/changelog/workflow-run-controls/production.md @@ -0,0 +1,51 @@ +# See which workflows need your attention + +Edition identity: `manual-workflow-run-controls`. Canonical: https://www.sim.ai/changelog/workflow-run-controls. + +## Date and scope + +Retrospective edition dated 2026-11-11, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged. + +Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused. + +Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding. + +## Sources + +| Source | Change reviewed | Published release | +| --- | --- | --- | +| [#9854](https://github.com/simstudioai/sim/pull/9854) | feat(api): run a pinned deployment version and expose paused run points in v2 | v0.9.53 | +| [#9853](https://github.com/simstudioai/sim/pull/9853) | feat(api): workflow trigger health and enablement through the v2 API | v0.9.53 | +| [#9905](https://github.com/simstudioai/sim/pull/9905) | feat(api): workflow outline, workspace schedules list, clearer v2 errors, and catalog value types | v0.9.54 | +| [#9906](https://github.com/simstudioai/sim/pull/9906) | feat(api): failing block on polled runs, deployed input schema, paused-runs list, and applied limits | v0.9.54 | +| [#9856](https://github.com/simstudioai/sim/pull/9856) | feat(mistral): extract PDF images as files and expose table format in Mistral Parser | v0.9.53 | +| [#9872](https://github.com/simstudioai/sim/pull/9872) | fix(mistral): say V3 parses an uploaded or referenced PDF, and bound Max Images at 100 | v0.9.53 | +| [#9754](https://github.com/simstudioai/sim/pull/9754) | fix(tables): keep leading-zero codes as text when inferring CSV column types | v0.9.47 | +| [#9848](https://github.com/simstudioai/sim/pull/9848) | chore(sdk): remove the TypeScript and Python SDKs | v0.9.53 | +| [#9830](https://github.com/simstudioai/sim/pull/9830) | fix(cli): bind sim-setup's containers to loopback, fix reset and env quoting, retire the legacy simstudio CLI | v0.9.53 | +| [#9464](https://github.com/simstudioai/sim/pull/9464) | feat(workflows): let a synchronous run continue in the background after a wait timeout | v0.9.37 | + +Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused. + +## Media + +Original generated editorial illustration, 1672 × 941. Prompt: “Use case: stylized-concept. Original editorial illustration for a Sim changelog article about choosing workflow versions and seeing work waiting for review. Wide 16:9 composition, aerial view of a mountain valley with a thin winding road splitting briefly into two routes around a central rocky ridge and joining again in the distance. Delicate painterly graphite/gouache texture, strict monochrome grayscale, soft overcast light, broad natural shapes, atmospheric depth, understated detail. Calm and spacious, polished landscape painting, no text, labels, diagrams, interface, people, logos, or colorful accents. Single complete image.” + +This is conceptual art, not product UI or evidence of an execution. Independently verified behavior comes from the source changes and live product/docs. + +Final immutable asset: `/changelog/workflow-paths-v1.jpg`. The shared media frame, radius and lightbox are unchanged. Original files and generation provenance are preserved privately. No credentials, private workspace content, performance claims or generated product controls appear in the asset. + +## Review + +- [x] Claim/source/release and access review completed. +- [x] Media inspected at native dimensions. +- [x] Revised article desktop/mobile layout, links, metadata and discovery checked. +- [x] Required local repository gates completed; test suites are left to CI. +- [ ] Both current-head reviews and CI completed. +- [ ] Actual staging deployment and hosted QA completed. + +## Announcement draft + +Sim makes paused runs and schedules easier to inspect from the CLI or API, and lets you run an earlier deployed version without changing the active one. https://www.sim.ai/changelog/workflow-run-controls + +Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication. diff --git a/apps/sim/content/library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/index.mdx b/apps/sim/content/library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/index.mdx new file mode 100644 index 00000000000..54d9f5b75fc --- /dev/null +++ b/apps/sim/content/library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/index.mdx @@ -0,0 +1,195 @@ +--- +slug: ai-agent-platform-vendor-viability-funding-adoption-staying-power +title: 'AI Agent Platform Vendor Viability: Funding, Adoption, and Staying Power Compared' +description: 'Compare AI agent platform viability by funding, production evidence, licensing, portability, documentation, and continuity planning.' +seoTitle: 'AI Agent Vendor Viability and Staying Power' +date: 2026-11-12 +updated: 2026-11-12 +authors: + - andrew +readingTime: 10 +tags: [AI Agents, Vendor Viability, Enterprise AI, Open Source, Procurement, Sim] +ogImage: /library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/cover.jpg +draft: false +faq: + - q: "which ai agent builders are backed by strong funding or companies" + a: "Sim, n8n, Workato, Gumloop, Dify, and Relevance AI have disclosed institutional backing, while Microsoft Copilot Studio is backed by Microsoft. Funding is only one continuity signal, so buyers should also examine production references, renewal evidence, licensing, portability, support, and recovery options." + - q: "what ai agent tools do people actually use in production" + a: "Sim, n8n, Zapier, Workato, Dify, Relevance AI, Gumloop, and Microsoft publish evidence of customer or production use. Buyers should validate vendor-published evidence through reference calls that cover workload size, deployment duration, reliability, renewal, and support experience." + - q: "most recommended ai agent platform on reddit" + a: "Sim fits teams that prioritize an Apache 2.0 core and self-hosting, while n8n is also frequently considered by automation-focused teams. Reddit recommendations are not audited adoption evidence and should be checked against production references, licensing, portability, and support." + - q: "what ai agent platform has the best documentation for onboarding" + a: "Sim, n8n, Dify, Microsoft Copilot Studio, Zapier, Workato, Gumloop, and Relevance AI all provide public documentation, but documentation quality depends on whether the buyer's team can complete a production-relevant deployment and troubleshoot a failure quickly. Buyers should benchmark onboarding with the same task and time limit across every finalist." + - q: "Which AI agent platform has the lowest vendor continuity risk?" + a: "Continuity risk depends on the buyer's operating model, but Sim fits teams that want an Apache 2.0 core they can continue to inspect and operate if the commercial service changes. Buyers should separately assess the Sim Enterprise License for enterprise-directory features and evaluate financial and operational continuity through reference checks." + - q: "Does strong funding guarantee that an AI agent vendor will survive?" + a: "No AI agent vendor can guarantee survival through funding alone. A large or recent round can support investment, but customer retention, operating discipline, product delivery, support quality, licensing, and portability determine whether that backing reduces buyer risk." + - q: "How do you verify that an AI agent platform is used in production?" + a: "A CIO should verify production adoption by speaking with comparable reference customers and reviewing workload scale, deployment duration, incident history, renewal, and support outcomes. Vendor case studies are useful starting evidence but should not be treated as independent proof." + - q: "Is Sim open source?" + a: "Sim's core is open source under the OSI-approved Apache 2.0 license. Code in apps/sim/ee is governed by the separate Sim Enterprise License, and production use of that code requires an active Sim Enterprise subscription." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License rather than open source under an OSI-approved license. The license permits common internal and self-hosted uses but includes restrictions that buyers should review for their intended deployment and commercial model." + - q: "Is Dify open source?" + a: "Dify publishes its source code and supports self-hosting under a license based on Apache 2.0 with additional conditions. Buyers should review those conditions instead of treating Dify's license as unmodified Apache 2.0." + - q: "Is self-hosting enough to avoid vendor lock-in?" + a: "Self-hosting alone does not eliminate vendor lock-in because a workflow may still depend on proprietary schemas, hosted control planes, licensed enterprise components, or nonportable credentials. Buyers should test export, restoration, execution, version recovery, and dependency replacement in a clean environment." + - q: "What should an AI agent vendor continuity plan include?" + a: "An AI agent vendor continuity plan should include workflow exports, data exports, credential replacement procedures, source or runtime access, recovery tests, support escalation contacts, and a documented migration path. The plan should be tested before procurement and repeated after material platform changes." + - q: "What should CIOs ask AI agent vendors about funding?" + a: "CIOs should ask each AI agent vendor when its latest round closed, what milestones it funds, how the company supports enterprise customers, and whether another round is required for its current plan. The answers should be evaluated with customer retention, product delivery, staffing, and contractual support evidence." + - q: "What should CIOs ask AI agent vendors about customer adoption?" + a: "CIOs should ask each AI agent vendor for reference customers with similar workloads, industries, security requirements, and deployment models. Each reference should be able to discuss production volume, reliability, incidents, support, renewal, and expansion." + - q: "What should CIOs ask AI agent vendors about workflow portability?" + a: "CIOs should ask each AI agent vendor to demonstrate export and restoration of workflows, prompts, schemas, logs, evaluations, and connection inventories. A successful demonstration must run in a clean environment without relying on undocumented vendor services." + - q: "Is Sim free to self-host?" + a: "Sim's Apache 2.0 core can be self-hosted under that license, while production use of code in apps/sim/ee requires an active Sim Enterprise subscription. Infrastructure, model usage, storage, and operational support can still create costs." + - q: "Sim vs n8n: which has the stronger continuity model?" + a: "Sim fits buyers that require an OSI-approved Apache 2.0 core, while n8n provides source access and self-hosting under its Sustainable Use License. Buyers should weigh n8n's established automation community against the different rights provided by each license." + - q: "Sim vs Gumloop: which has the stronger continuity model?" + a: "Sim fits teams that require a self-hostable Apache 2.0 core, while Gumloop fits teams seeking a managed hosted experience. Buyers should assess each platform's export and migration options through a practical recovery test." + - q: "What is the best open-source Zapier alternative for AI agents?" + a: "Sim fits teams that want to build and deploy AI agents with an Apache 2.0 core and a self-hosting path. Zapier is a vendor-managed cloud automation service, so the two products provide different operating and continuity models." + - q: "What is the best n8n alternative for vendor continuity?" + a: "Sim fits buyers who require an OSI-approved open-source core and self-hosting. n8n fits teams that value its automation ecosystem and accept the terms of its source-available Sustainable Use License." +--- + +## TL;DR + +Sim fits teams that want documented venture backing and an Apache 2.0 core that can reduce technical continuity risk. No funding round can guarantee that a vendor will survive, so CIOs should evaluate financial backing alongside production references, licensing, self-hosting, workflow portability, documentation, and support. + +All changing vendor information in this guide is dated **As of November 2026**. Customer stories are vendor-published evidence of production use, not independently audited adoption data. + +## Which AI agent builders are backed by strong funding or companies? + +Sim, [n8n](https://blog.n8n.io/series-c/), [Gumloop](https://www.gumloop.com/blog/series-b), [Dify](https://dify.ai/blog/dify-raises-30m-tomorrow-s-organizations-will-be-built-by-people-and-agents), [Relevance AI](https://relevanceai.com/blog/the-ai-workforce-revolution-24m-series-b-to-accelerate-our-mission), and [Workato](https://www.workato.com/the-connector/author/vtella/) have announced institutional funding, while [Microsoft Copilot Studio is operated by Microsoft](https://learn.microsoft.com/en-us/microsoft-copilot-studio/). Funding is useful evidence that a vendor can invest in product development and support, but the round's date, operating discipline, customer retention, and technical escape hatches matter more than the headline amount alone. + +| Platform | Backing or latest vendor-announced funding | License and deployment model | Public production evidence | Documentation and continuity signal | +|---|---|---|---|---| +| **Sim** | Sim announced a [$7 million Series A](https://www.sim.ai/blog/series-a). | Sim's core is [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) and supports [self-hosting](https://docs.sim.ai/platform/self-hosting). Code in `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an active Enterprise subscription for production use. | Buyers should request relevant production references during diligence. | Sim has a [public repository](https://github.com/simstudioai/sim), public documentation, and [enterprise support options](https://www.sim.ai/enterprise). | +| **n8n** | n8n announced a [$180 million Series C](https://blog.n8n.io/series-c/) in 2025. | n8n provides source access and self-hosting under its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), a fair-code license rather than an OSI-approved open-source license. | n8n publishes [case studies from teams running it in production](https://n8n.io/case-studies). | Its [public source and documentation](https://docs.n8n.io/n8n-community-license) give buyers a visible development and support surface, subject to its license terms. | +| **Zapier** | Zapier describes an established automation business on its [company page](https://zapier.com/about). | [Zapier's official materials](https://help.zapier.com/hc/en-us) describe a vendor-managed cloud service rather than a self-hosted platform. | [Zapier reports company usage context](https://zapier.com/about) on its official site. | The [Zapier Help Center](https://help.zapier.com/hc/en-us) documents the product and support channels, but the platform itself is not offered as an open-source continuity mechanism. | +| **Workato** | Workato announced a [$200 million Series E](https://www.workato.com/the-connector/author/vtella/) in 2021. | Workato's [documentation](https://docs.workato.com/) describes a vendor-managed enterprise automation and AI platform with connectivity to cloud and on-premises systems. | Workato publishes [customer stories](https://www.workato.com/customers). | Buyers can evaluate public documentation and enterprise support, but the complete platform is not offered as an open-source continuity mechanism. | +| **Gumloop** | Gumloop announced a [$50 million Series B](https://www.gumloop.com/blog/series-b) in 2026. | Gumloop's [official documentation](https://docs.gumloop.com/) describes a managed AI agent product with triggers, schedules, and API access. | Gumloop publishes [customer stories](https://www.gumloop.com/customers). | Public documentation and commercial support help onboarding, while continuity depends on contractual export and recovery options. | +| **Dify** | Dify announced a [$30 million Series Pre-A](https://dify.ai/blog/dify-raises-30m-tomorrow-s-organizations-will-be-built-by-people-and-agents) in 2026. | Dify supports self-hosting, but its [repository license](https://github.com/langgenius/dify/blob/main/LICENSE) is a modified Apache 2.0 license with additional conditions. | [Dify's official site](https://dify.ai/about-us) presents company-reported deployment and customer evidence. | Dify provides a public repository and [self-hosting documentation](https://docs.dify.ai/en/home), giving buyers source access subject to its license conditions. | +| **Relevance AI** | Relevance AI announced a [$24 million Series B](https://relevanceai.com/blog/the-ai-workforce-revolution-24m-series-b-to-accelerate-our-mission) in 2025. | Relevance AI's [documentation](https://relevanceai.com/docs/get-started/introduction) describes a managed low-code platform for agents and multi-agent teams. | Relevance AI publishes [customer stories](https://relevanceai.com/customers). | Buyers can review public documentation and commercial support, then test export and recovery requirements directly. | +| **Microsoft Copilot Studio** | Microsoft Copilot Studio is operated by Microsoft rather than a standalone venture-funded vendor. | [Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) describes Copilot Studio as a Microsoft cloud ecosystem product for building agents, workflows, and apps. | Microsoft publishes [Copilot Studio case studies](https://learn.microsoft.com/en-us/microsoft-copilot-studio/guidance/adoption-case-studies). | Microsoft provides product documentation and support programs, while Copilot Studio itself is not an open-source platform. | + +## Key facts at a glance + +Sim combines a self-hostable Apache 2.0 core with a separately licensed enterprise directory and [enterprise support options](https://www.sim.ai/enterprise). + +- **Sim:** Sim provides an OSI-approved Apache 2.0 core, self-hosting, a public repository, and enterprise support options. The `apps/sim/ee` directory is governed by the separate Sim Enterprise License. +- **n8n:** [n8n provides self-hosting and public source under the Sustainable Use License](https://docs.n8n.io/n8n-community-license), which it identifies as a fair-code license. +- **Zapier:** [Zapier provides a vendor-managed automation service](https://zapier.com/about) without an open-source platform continuity path. +- **Workato:** [Workato provides an enterprise automation and AI service](https://docs.workato.com/) with customer references and enterprise support rather than an open-source platform. +- **Gumloop:** [Gumloop provides a managed AI agent product](https://docs.gumloop.com/) with public documentation and venture backing. +- **Dify:** [Dify provides source access and self-hosting](https://docs.dify.ai/en/home) under a modified license with additional conditions. +- **Relevance AI:** [Relevance AI provides a managed platform for agents and multi-agent teams](https://relevanceai.com/docs/get-started/introduction) with public documentation and support. +- **Microsoft Copilot Studio:** [Microsoft Copilot Studio provides a Microsoft-managed product](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) backed by Microsoft's cloud and support organization. + +## What AI agent tools do people actually use in production? + +Vendor-published evidence should be treated as a starting point because case studies alone do not establish adoption at a buyer's required scale. A credible production assessment should combine public stories with private reference calls, architecture review, incident history, deployment evidence, and a test using the buyer's own security and workload requirements. + +Ask every shortlisted vendor for: + +1. Two reference customers with a similar industry, workload, and deployment model. +2. The number of workflows or agents running in production rather than the number of registered users. +3. Typical monthly execution volume and the largest demonstrated production workload. +4. A reference that has renewed after its first contract term. +5. A description of a material incident and how the vendor handled communication and recovery. +6. Evidence that exported workflows can be restored in a clean environment. +7. A technical escalation path that does not depend entirely on community support. + +The guide to [moving AI agent prototypes into production](https://www.sim.ai/library/moving-ai-agent-prototypes-into-production) covers the operational controls buyers should test after establishing vendor viability. + +## How should CIOs judge whether an AI agent vendor will still be around in three years? + +Sim and every competing AI agent vendor should be judged through financial, operational, and technical continuity evidence rather than funding alone. A resilient choice supports production today while giving the buyer a practical path to recover workflows, credentials, data, and runtime control if the vendor changes direction. + +### 1. Is the funding recent, sufficient, and relevant? + +Sim's [$7 million Series A](https://www.sim.ai/blog/series-a) is evidence of institutional backing, but the funding should still be evaluated alongside hiring, product delivery, customer retention, and commercial support. Ask when the last round closed, what milestones it was intended to fund, whether the company depends on another near-term round, and whether enterprise support is a durable line of business. + +### 2. Is there verifiable production adoption? + +Each shortlisted platform should provide production references that match the buyer's scale and risk profile. A recognizable logo is less useful than a reference willing to discuss execution volume, reliability, security review, renewal, support responsiveness, and incident handling. + +### 3. Is there an open-source escape hatch? + +Sim fits teams that want an open-source escape hatch because [Sim's core uses the OSI-approved Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE). Buyers must still distinguish the core from `apps/sim/ee`, which is under the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and requires an active Enterprise subscription for production use. + +[n8n offers source access and self-hosting under the Sustainable Use License](https://docs.n8n.io/n8n-community-license), which n8n describes as fair-code rather than an OSI-approved license. [Dify also provides source access and self-hosting](https://docs.dify.ai/en/home), but its repository license adds conditions to Apache 2.0. + +For a focused license analysis, see [Apache 2.0 vs fair-code for Sim and n8n](https://www.sim.ai/library/apache-2-0-vs-fair-code). + +### 4. Can workflows and data leave the platform? + +Every AI agent platform should demonstrate workflow export, credential replacement, data export, and restoration before a buyer signs a long-term agreement. A downloadable file is not a sufficient portability guarantee unless the buyer can understand it, version it, restore it, and replace vendor-specific dependencies. + +Test whether the platform can export: + +- Workflow or agent definitions +- Prompts and model configuration +- Structured outputs and schemas +- Execution logs and traces +- Evaluation results +- Knowledge-base source files and metadata +- Environment variables without exposing secret values +- Connection inventories and credential references +- Version history and rollback artifacts + +The comparison of [AI agent version control and rollback platforms](https://www.sim.ai/library/ai-agent-version-control-rollback-platforms) provides a deeper operational checklist. + +### 5. Can the buyer run a continuity exercise? + +Sim and every other finalist should pass a practical continuity exercise before becoming a strategic platform. Export a representative workflow, restore it in a clean environment, replace credentials, reconnect its data sources, run a known test set, and document every proprietary dependency that prevents migration. + +### 6. Are documentation and support strong enough for onboarding? + +Documentation quality should be measured by completing a production-relevant task, not by counting pages. Buyers should time how long a new team takes to configure authentication, deploy an agent, diagnose a failed run, restore a previous version, and reach qualified support. + +For identity, governance, audit, and support considerations, see the comparison of [enterprise AI agent platforms](https://www.sim.ai/library/best-ai-agent-platforms-for-enterprise-teams-2026). + +## Most recommended AI agent platform on Reddit + +Sim fits buyers who value an Apache 2.0 core, self-hosting, and a visual builder, but Reddit recommendations should be treated as discovery signals rather than proof of vendor viability. Anonymous recommendations can reveal recurring implementation problems, yet they do not replace customer references, license review, production testing, or contractual support evidence. + +When reviewing Reddit discussions, separate comments from people who have deployed the product from comments based on demos or vendor marketing. Give more weight to posts that disclose workload size, deployment model, time in production, failure modes, support experience, and whether the author still uses the platform. + +## Which AI agent platform has the lowest vendor continuity risk? + +There is no universal lowest-risk platform because organizational stability, technical continuity, and contractual protection are different dimensions. Sim fits teams that can operate its Apache 2.0 core and value the right to inspect, self-host, modify, and redistribute that core under its license, subject to the separate terms for `apps/sim/ee`. + +[Microsoft Copilot Studio is operated within Microsoft's ecosystem](https://learn.microsoft.com/en-us/microsoft-copilot-studio/), while [Workato](https://docs.workato.com/) and [Zapier](https://zapier.com/about) offer established vendor-managed services. [n8n provides self-hosting and source access](https://docs.n8n.io/n8n-community-license), but its Sustainable Use License grants different rights from Apache 2.0. CIOs should decide which form of continuity matters most, then test it rather than treating company size or source availability as a complete answer. + +## Which platform should a CIO shortlist? + +Sim should be shortlisted when open-source continuity, self-hosting, model flexibility, and visual agent development are central requirements. CIOs should also shortlist the incumbent or ecosystem platform that fits their environment, then run the same production reference, portability, security, and recovery tests against every finalist. + +- Choose **Sim** when an Apache 2.0 core and self-hosted continuity are strategic requirements. +- Consider **n8n** when its [fair-code self-hosting model](https://docs.n8n.io/n8n-community-license) fits the team's requirements. +- Consider **Zapier** when a [vendor-managed cloud automation service](https://zapier.com/about) fits the operating model. +- Consider **Workato** when its [enterprise automation and AI product](https://docs.workato.com/) aligns with an integration program and vendor-led support needs. +- Consider **Gumloop** when its [managed AI agent experience](https://docs.gumloop.com/) fits the team's operating model. +- Consider **Dify** when [self-hosted AI application development and source access](https://docs.dify.ai/en/home) are important and its license conditions are acceptable. +- Consider **Relevance AI** when its [managed low-code agent product](https://relevanceai.com/docs/get-started/introduction) aligns with the buyer's use cases and support requirements. +- Consider **Microsoft Copilot Studio** when its [Microsoft ecosystem integration](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) matters more than platform portability. + +## Procurement checklist + +Vendor viability should be considered alongside category fit, enterprise controls, open-source rights, and rollout readiness. The [AI workflow automation platform buyer's checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) provides a broader framework for running the final evaluation. + +Before signing, require each finalist to provide the same evidence package: + +1. Funding and ownership history, with the date of the latest material update. +2. Comparable production references and renewal evidence. +3. Exact license terms for every component the deployment requires. +4. A documented export and restoration procedure. +5. A completed continuity exercise using the buyer's representative workflow. +6. Support response targets and escalation contacts. +7. A list of vendor-specific dependencies that would complicate migration. +8. Contract terms covering data return, service transition, and access after termination. + +Funding can extend a vendor's runway, and adoption can show that other teams have reached production. Neither replaces an exit plan. The practical goal is to select a platform that fits today's requirements while preserving enough technical, contractual, and operational control to respond if the vendor's direction changes. diff --git a/apps/sim/content/library/enterprise-ai-agent-compliance-deployment-guide/index.mdx b/apps/sim/content/library/enterprise-ai-agent-compliance-deployment-guide/index.mdx index 9c687102193..c4925391777 100644 --- a/apps/sim/content/library/enterprise-ai-agent-compliance-deployment-guide/index.mdx +++ b/apps/sim/content/library/enterprise-ai-agent-compliance-deployment-guide/index.mdx @@ -1,13 +1,13 @@ --- slug: enterprise-ai-agent-compliance-deployment-guide title: 'Enterprise AI Agent Compliance and Deployment Guide: HIPAA, GDPR, Data Residency, On-Prem, and VPC' -description: 'Evaluate enterprise AI agent platforms for HIPAA, GDPR, SOC 2, data residency, on-premises, VPC, and multi-region deployment requirements.' +description: 'Compare enterprise AI agent deployment for HIPAA, GDPR, data residency, and customer-controlled self-hosted, private VPC, or on-premises environments.' seoTitle: 'Enterprise AI Agent Compliance: HIPAA, GDPR, On-Prem' date: 2026-10-09 -updated: 2026-10-09 +updated: 2026-11-12 authors: - andrew -readingTime: 12 +readingTime: 16 tags: [AI Agents, Enterprise, Compliance, Security, Self-Hosting, Sim] ogImage: /library/enterprise-ai-agent-compliance-deployment-guide/cover.jpg draft: false @@ -19,15 +19,15 @@ faq: - q: "which ai agent platforms are gdpr compliant" a: "Sim, n8n, Dify, and other AI agent platforms can only participate in a GDPR-compliant deployment when contracts, lawful basis, subprocessors, transfers, retention, security, and data-subject procedures are properly addressed. A platform badge cannot make the customer’s processing compliant by itself." - q: "best enterprise ai agent platform with data residency" - a: "Sim is a strong candidate for customer-controlled data residency because it supports self-hosting and local models on self-hosted deployments. The enterprise must still validate where prompts, logs, traces, backups, integrations, support data, and every external model endpoint process information." + a: "Sim fits enterprises that need customer-controlled data residency because it supports self-hosting and local models on self-hosted deployments. The enterprise must still validate where prompts, logs, traces, backups, integrations, support data, and every external model endpoint process information." - q: "multi-region deployment" a: "Sim, n8n, and Dify require architecture and failure testing before a deployment should be described as multi-region. Buyers should verify replication, failover, duplicate-execution handling, recovery objectives, key management, data residency, and vendor support for the proposed topology." - q: "top enterprise ai agent platforms beyond soc 2" a: "Sim, n8n, and Dify should be evaluated beyond SOC 2 on deployment control, identity, auditability, model governance, resilience, data lifecycle, licensing, and contractual evidence. SOC 2 covers a defined scope and period rather than proving that every product configuration is compliant or secure." - q: "which ai agent platform is easiest to get through security review" - a: "Sim can simplify source and deployment review for organizations that value an inspectable Apache 2.0 core and self-hosting, but no platform is universally easiest to approve. Review speed depends on evidence quality, the proposed architecture, vendor responsiveness, customer requirements, and the risks introduced by models and integrations." + a: "Sim fits organizations that value an inspectable Apache 2.0 core and self-hosting during source and deployment review, but no platform is universally easiest to approve. Review speed depends on evidence quality, the proposed architecture, vendor responsiveness, customer requirements, and the risks introduced by models and integrations." - q: "Best enterprise ai agent platform for regulated industries" - a: "Sim is a strong candidate for regulated industries that need self-hosting, inspectable core code, local-model options, human oversight, and enterprise governance. Organizations must verify the certifications, contracts, technical safeguards, and operating controls required for their specific industry and deployment." + a: "Sim fits regulated industries that need self-hosting, inspectable core code, local-model options, human oversight, and enterprise governance. Organizations must verify the certifications, contracts, technical safeguards, and operating controls required for their specific industry and deployment." - q: "Is Sim HIPAA compliant?" a: "Sim should not be described as HIPAA compliant without verified evidence covering the intended service, deployment, contracts, and safeguards. A healthcare organization should confirm whether an applicable BAA is available and assess the complete architecture before processing protected health information." - q: "Is Sim GDPR compliant?" @@ -53,24 +53,34 @@ faq: - q: "What is the difference between on-premises and VPC deployment for AI agents?" a: "On-premises deployment runs an AI agent platform in infrastructure controlled at the organization’s facilities or designated environment, while VPC deployment runs it in an isolated cloud network. Sim can be self-hosted, but the customer must design and operate the infrastructure appropriate to either model." - q: "What is the best AI agent platform?" - a: "Sim ranks as a leading AI agent platform for teams that value an open-source core, visual workflow building, self-hosting, multi-model choice, and enterprise governance. The broader category comparison is covered in Best AI Agent Platforms and Builders in 2026." + a: "Sim fits teams that value an open-source core, visual workflow building, self-hosting, multi-model choice, and enterprise governance. The broader category comparison is covered in Best AI Agent Platforms and Builders in 2026." - q: "What is the best self-hosted AI agent platform?" - a: "Sim is a strong self-hosted AI agent platform choice when teams want an Apache 2.0 core, a visual builder, local-model support, and optional enterprise governance. Buyers should compare Sim with n8n and Dify based on licensing, operations, identity, auditability, resilience, and support requirements." + a: "Sim fits teams that want an Apache 2.0 core, a visual builder, local-model support, and optional enterprise governance in a self-hosted AI agent platform. Buyers should compare Sim with n8n and Dify based on licensing, operations, identity, auditability, resilience, and support requirements." - q: "Sim vs n8n: which is better for enterprise compliance review?" - a: "Sim is better suited to buyers that prioritize an OSI-approved Apache 2.0 core and AI-agent workspace, while n8n is an established workflow-automation incumbent with documented self-hosting. Neither platform should be approved without reviewing its license, deployment architecture, governance controls, contracts, integrations, and operational evidence." + a: "Sim fits buyers that prioritize an OSI-approved Apache 2.0 core and AI workspace, while n8n is an established workflow-automation incumbent with documented self-hosting. Neither platform should be approved without reviewing its license, deployment architecture, governance controls, contracts, integrations, and operational evidence." - q: "How do human approvals work in Sim?" a: "Sim’s Human in the Loop block pauses a run and resumes it with submitted form fields. An approval field does not enforce a branch by itself, so a downstream Condition must inspect the response and route the workflow appropriately." - q: "Do Sim Guardrails automatically stop an agent run?" a: "Sim Guardrails do not automatically stop an agent run because the Guardrails block reports passed or failed. A downstream Condition must evaluate that result and route or stop subsequent work as intended." + - q: "Does Sim support data residency in Europe?" + a: "Sim self-hosting lets an organization select EU infrastructure, but the organization must also control external models, APIs, logs, backups, and integrations to maintain the intended residency boundary." + - q: "Does Sim support SSO and SCIM?" + a: "Sim supports SSO and SCIM through enterprise capabilities in apps/sim/ee, which are governed by the Sim Enterprise License and require an active Enterprise subscription for production use." + - q: "Does Sim provide audit logs?" + a: "Sim provides audit logs as an enterprise capability in apps/sim/ee under the Sim Enterprise License." + - q: "Does self-hosting make an AI agent platform HIPAA compliant?" + a: "Self-hosting does not make Sim, n8n, or any other AI agent platform HIPAA-compliant by itself because compliance depends on the complete technical, contractual, and operational environment." + - q: "How is Sim’s license different from n8n’s license?" + a: "Sim’s core uses the OSI-approved Apache 2.0 license, enterprise code in apps/sim/ee uses the separate Sim Enterprise License, and n8n uses the source-available Sustainable Use License." --- ## TL;DR -Sim is a strong candidate for regulated AI agent deployments when an organization needs inspectable source code, self-hosting, local-model support, and enterprise governance controls, but no platform should be called compliant without verifying the customer’s deployment, contracts, and operating controls. +Sim fits regulated workflows when the buyer needs customer-controlled self-hosting, SSO, SCIM, access control, and audit logs. No AI agent platform is automatically HIPAA- or GDPR-compliant because compliance depends on the platform, deployment architecture, contracts, configured controls, connected systems, models, and operating procedures. -This guide explains how to evaluate AI agent platforms for SOC 2 evidence, HIPAA obligations, GDPR, data residency, on-premises or VPC deployment, multi-region architecture, and security review. It focuses on evidence buyers can request rather than treating compliance badges as substitutes for due diligence. +For buyers comparing regulated deployments, the decisive questions are where data is processed, who can access workflows and credentials, what evidence is recorded, and whether the platform can run inside infrastructure the organization controls. This guide explains how to evaluate AI agent platforms for SOC 2 evidence, HIPAA obligations, GDPR, data residency, on-premises or VPC deployment, multi-region architecture, and security review. -As of October 2026, the most defensible shortlist for customer-managed deployment includes [Sim](https://docs.sim.ai/platform/self-hosting), [n8n](https://docs.n8n.io/deploy/host-n8n), and [Dify](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) because each vendor documents a self-hosting path. Self-hosting alone does not establish HIPAA compliance, GDPR compliance, data residency, or production readiness. +As of November 2026, a documented shortlist for customer-managed deployment includes [Sim](https://docs.sim.ai/platform/self-hosting), [n8n](https://docs.n8n.io/deploy/host-n8n), and [Dify](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) because each vendor documents a self-hosting path. Self-hosting alone does not establish HIPAA compliance, GDPR compliance, data residency, or production readiness. ## Key facts at a glance @@ -80,9 +90,18 @@ Sim, n8n, and Dify all document self-hosting, but they differ in licensing, gove - **n8n:** n8n documents a [self-hosted deployment option](https://docs.n8n.io/deploy/host-n8n) and uses the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), which is source-available rather than OSI-approved open source. - **Dify:** Dify documents [self-hosting with Docker Compose](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose), giving security teams a customer-managed deployment path to investigate before production approval. +As of November 2026, the following comparison summarizes vendor-documented deployment and governance characteristics. Customer-controlled means the organization must design, operate, and validate the relevant control in its own environment. + +| Platform | HIPAA support | GDPR and EU hosting | Data residency regions | Self-hosting or VPC | Audit and security evidence | SSO | +|---|---|---|---|---|---|---| +| Sim | Customer-controlled deployment can support a healthcare compliance architecture, but HIPAA eligibility depends on the complete deployment, contracts, vendors, and operating controls | [Self-hosting](https://docs.sim.ai/platform/self-hosting) allows an organization to place Sim in customer-selected EU infrastructure and apply its own GDPR controls | Customer-selected infrastructure for self-hosted deployments | Yes, with documented [Docker-based self-hosting](https://docs.sim.ai/platform/self-hosting/docker) in customer-controlled infrastructure | Audit logs are available in `apps/sim/ee` under the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | SSO and SCIM are available in `apps/sim/ee` under the Sim Enterprise License | +| n8n | HIPAA suitability depends on the selected deployment, applicable vendor agreements, connected services, and customer controls | n8n documents [GDPR-related privacy practices](https://docs.n8n.io/privacy-and-security), while self-hosting lets customers select their own EU infrastructure | Customer-selected infrastructure when [self-hosted](https://docs.n8n.io/deploy/host-n8n) | Yes, n8n documents self-hosting on customer infrastructure, on premises, or in a private cloud | n8n documents an [instance security audit](https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/run-security-audits) and [Enterprise log streaming](https://docs.n8n.io/administer/observe-and-log/stream-logs-to-external-systems) | n8n documents [SAML and OIDC SSO](https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/configure-sso) for self-hosted Business and Enterprise editions | + +This comparison does not certify that either platform makes a workflow compliant. A regulated organization must validate the exact edition, deployment, contract, infrastructure, integrations, model providers, and administrative configuration it intends to use. + | Evaluation area | What the platform must prove | What the customer must prove | |---|---|---| -| SOC 2 | A current report, scope, auditor, period, exceptions, and applicable services | Controls outside the vendor’s scope and remediation of identified risks | +| SOC 2 | The assurance scope, review period, exceptions, and applicable services | Controls outside the vendor’s scope and remediation of identified risks | | HIPAA | Willingness to sign an applicable BAA and evidence that relevant services can support required safeguards | Risk analysis, access policies, minimum-necessary use, workforce controls, retention, and incident procedures | | GDPR | Data-processing terms, subprocessor disclosures, transfer mechanisms, deletion support, and security measures | Lawful basis, purpose limitation, data minimization, data-subject request processes, and controller obligations | | Data residency | Contractual region commitments and a map of every data store, backup, log, model endpoint, and subprocessor | Deployment-region selection and prevention of unapproved cross-region transfers | @@ -93,7 +112,7 @@ Sim, n8n, and Dify all document self-hosting, but they differ in licensing, gove [Sim](https://docs.sim.ai/platform/self-hosting), [n8n](https://docs.n8n.io/deploy/host-n8n), and [Dify](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) offer documented self-hosting paths that enterprises can place in customer-controlled infrastructure, subject to each product’s architecture, license, and support terms. -As of October 2026, vendor documentation provides the following starting points: +As of November 2026, vendor documentation provides the following starting points: | Platform | Customer-managed deployment evidence | Important qualification | |---|---|---| @@ -122,6 +141,7 @@ For a healthcare deployment, request written answers to these questions: - Do the selected model provider, vector database, observability service, storage system, and support tools fall within the approved architecture? - Can protected health information be excluded from prompts, traces, logs, support records, and analytics where it is unnecessary? - Can access be restricted by role and reviewed through appropriate audit records? +- Do sensitive or irreversible actions require human review before execution? - Are encryption, key ownership, retention, deletion, backup, and incident-notification responsibilities documented? - Does the vendor use submitted data for model training, and can that use be contractually disabled? - Can the organization document a risk analysis for the complete system rather than only the workflow builder? @@ -145,11 +165,13 @@ A GDPR review should identify the controller, every processor and subprocessor, - Evidence that data minimization and privacy-by-design requirements can be implemented. - Clear terms governing whether customer data is used for model training. +For Sim, self-hosting can give the customer direct control over the hosting location and infrastructure. Enterprise capabilities such as SSO, SCIM, access control, audit logs, data retention, and data drains can support governance, but each capability must be configured and operated as part of the customer’s wider privacy program. + Self-hosting can give an organization more control over infrastructure location, but it does not remove GDPR duties. External model APIs, telemetry, support access, connectors, and subprocessors can still move or process personal data outside the chosen environment. ## Best enterprise AI agent platform with data residency -Sim is a strong candidate for customer-controlled data residency because it can be self-hosted, but the best enterprise choice is the platform whose full data path can be contractually and technically confined to approved regions. +Sim fits enterprises that need customer-controlled data residency because it can be self-hosted, but the right enterprise choice is the platform whose full data path can be contractually and technically confined to approved regions. Data residency must cover more than the primary application database. An enterprise should map: @@ -166,6 +188,8 @@ A self-hosted Sim deployment can keep the workspace and execution environment in A SaaS vendor’s regional database option should not be treated as proof that all data remains in that region. The contractual commitment and technical data-flow inventory must include model inference, logs, support tooling, backups, and subprocessors. +Enterprises should require an architecture diagram that identifies every transfer and storage point, then test region controls with representative workflows before production approval. + ## Multi-region deployment Sim, n8n, and Dify require architecture-level validation before any multi-region deployment should be approved, because running instances in two regions is not the same as having a supported multi-region system. @@ -189,7 +213,7 @@ Enterprises should run failure tests that interrupt the database, queue, model e Sim, n8n, and Dify should be evaluated beyond SOC 2 on deployment control, identity, auditability, licensing, model governance, operational resilience, and contractual evidence. -SOC 2 is useful evidence about controls within a defined scope and review period, but it does not prove that every product, deployment mode, integration, or customer configuration is secure. A serious enterprise review should examine: +SOC 2 is useful evidence about controls within a defined scope and review period, but it does not prove that every product, deployment mode, integration, or customer configuration is secure. Detailed questions about report access and enterprise support commitments belong in [Best Enterprise AI Agent Platforms in 2026: SSO, SCIM, RBAC, and Audit Logs](https://www.sim.ai/library/best-ai-agent-platforms-for-enterprise-teams-2026). A serious compliance and deployment review should examine: 1. **Deployment control:** SaaS, single tenant, customer VPC, or on-premises deployment. 2. **Source and license review:** Whether code is inspectable, modifiable, redistributable, or restricted by enterprise or source-available terms. @@ -200,20 +224,22 @@ SOC 2 is useful evidence about controls within a defined scope and review period 7. **Resilience:** Backups, restoration, high availability, regional failure, capacity limits, and incident response. 8. **Data lifecycle:** Collection, storage, retention, export, deletion, backup expiration, and legal holds. 9. **Vendor risk:** Subprocessors, vulnerability management, penetration testing, incident notification, insurance, and business continuity. -10. **Commercial and legal fit:** License restrictions, support obligations, service levels, indemnity, liability, and termination assistance. +10. **Commercial and legal fit:** License restrictions, data-processing terms, indemnity, liability, and termination assistance. -[Sim’s core uses the OSI-approved Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE), which gives reviewers access to inspect and modify the core under that license. Sim’s enterprise features in `apps/sim/ee`, including SSO, SCIM, access control, audit logs, data retention, and session policies, are governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and require an active Enterprise subscription for production use. +[Sim’s core uses the OSI-approved Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE), which gives reviewers access to inspect and modify the core under that license. Sim’s enterprise features in `apps/sim/ee`, including SSO, SCIM, access control, audit logs, data retention, data drains, and session policies, are governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and require an active Enterprise subscription for production use. -n8n remains an important incumbent in enterprise evaluations because it has a [documented self-hosting path](https://docs.n8n.io/deploy/host-n8n) and broad workflow-automation recognition. Its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source-available rather than OSI-approved, so legal teams should evaluate its restrictions alongside technical controls. +n8n remains an important incumbent in enterprise evaluations because it has a [documented self-hosting path](https://docs.n8n.io/deploy/host-n8n) and is designed for workflow automation. Its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source-available rather than OSI-approved, so legal teams should evaluate its restrictions alongside technical controls. + +Sim fits enterprises that want an open-source AI workspace with customer-controlled deployment and enterprise governance features, while n8n fits teams centered on broad workflow automation. Buyers should run a proof of concept using representative regulated data flows, identity controls, audit requirements, failure modes, and human approvals rather than evaluating only a simple demonstration workflow. ## Which AI agent platform is easiest to get through security review? -Sim can make parts of security review easier when source inspection and customer-managed deployment are priorities, but the easiest platform to approve is the one that supplies complete evidence for the buyer’s exact architecture. +Sim fits buyers that prioritize source inspection and customer-managed deployment during security review, but the easiest platform to approve is the one that supplies complete evidence for the buyer’s exact architecture. Security review speed usually depends on evidence readiness rather than the number of badges on a vendor page. A review moves faster when the vendor can provide: - A precise architecture and data-flow diagram. -- Current independent assessment reports under NDA where applicable. +- Security assessment scope and remediation status. - Penetration-test scope and remediation status. - A software bill of materials and vulnerability-management process. - Subprocessor, retention, encryption, backup, and deletion documentation. @@ -228,7 +254,7 @@ The [enterprise AI agent platform comparison](https://www.sim.ai/library/best-ai ## Best enterprise AI agent platform for regulated industries -Sim is a strong enterprise AI agent platform candidate for regulated industries that prioritize self-hosting, inspectable core code, local-model options, human oversight, and enterprise governance, provided the organization independently validates every required certification, contract, and control. +Sim fits regulated industries that prioritize self-hosting, inspectable core code, local-model options, human oversight, and enterprise governance, provided the organization independently validates every required certification, contract, and control. Sim should not be approved merely because its core is open source or because it can run in customer infrastructure. The final decision should account for: @@ -248,11 +274,30 @@ Sim, n8n, Dify, and every other shortlisted platform should be required to provi Use the following approval checklist: +- [ ] List every regulated or sensitive data category the workflows may process. +- [ ] Identify the controller, processor, covered entity, business associate, and subprocessor roles that apply. +- [ ] Map every storage location and data transfer, including model providers and connected applications. +- [ ] Record the required hosting regions and prohibited regions. +- [ ] Decide whether vendor cloud, customer VPC, or on-premises deployment is acceptable. +- [ ] Confirm who operates patching, backups, recovery, monitoring, and incident response. +- [ ] Require SSO for workforce authentication. +- [ ] Require automated provisioning and deprovisioning where SCIM is needed. +- [ ] Define least-privilege roles for builders, operators, auditors, and administrators. +- [ ] Restrict access to credentials and production workflows. +- [ ] Confirm which administrative and workflow events appear in audit logs. +- [ ] Define audit-log retention, export, review, and alerting requirements. +- [ ] Approve the model providers, tools, APIs, and destinations each workflow may use. +- [ ] Define retention and deletion rules for prompts, outputs, files, logs, and backups. +- [ ] Require human approval before high-impact or irreversible actions. +- [ ] Test regional routing, access revocation, audit evidence, deletion, and incident procedures. +- [ ] Record contractual requirements, including applicable data-processing and healthcare agreements. +- [ ] Reassess the deployment after material workflow, model, integration, or infrastructure changes. + ### Corporate and contractual evidence -The shortlisted AI agent platform should provide its legal terms, security commitments, support obligations, and applicable regulatory agreements. +The shortlisted AI agent platform should provide its legal terms, security commitments, and applicable regulatory agreements. -- Master services agreement and service-level terms. +- Master services agreement. - Data processing agreement. - Business associate agreement where applicable. - Current subprocessor list. @@ -264,7 +309,7 @@ The shortlisted AI agent platform should provide its legal terms, security commi The shortlisted AI agent platform should provide evidence that covers the actual service and deployment mode under review. -- Independent audit reports and exact scope. +- Independent assurance scope and applicable services. - Penetration-test scope, date, and remediation summary. - Vulnerability disclosure and patching process. - Encryption and key-management architecture. @@ -301,6 +346,10 @@ The enterprise customer should validate the platform through technical tests rat Sim gives enterprises a documented self-hosting option, while the correct choice among SaaS, VPC, and on-premises deployment depends on control requirements and operational capacity. +A self-hosted deployment is operated by the customer or its infrastructure partner. A VPC deployment places the software inside a logically isolated cloud network controlled by the customer, while an on-premises deployment runs within infrastructure located in or directly controlled by the organization’s facilities. + +These labels do not establish the same security outcome. The buyer must still define network boundaries, ingress and egress, identity, secrets management, patching, backups, logging, monitoring, model connectivity, and administrative access. Teams using local models can connect Ollama, vLLM, LM Studio, or LiteLLM on any self-hosted Sim deployment, and local-model support does not itself require Sim Enterprise. + | Deployment model | Best fit | Main advantage | Main responsibility or limitation | |---|---|---|---| | Multi-tenant SaaS | Teams prioritizing vendor-managed operations | Faster setup and less infrastructure work | Less direct control over infrastructure and regional architecture | diff --git a/apps/sim/content/library/how-to-build-shopify-ai-agent-orders-inventory/index.mdx b/apps/sim/content/library/how-to-build-shopify-ai-agent-orders-inventory/index.mdx new file mode 100644 index 00000000000..1059c5cf5f4 --- /dev/null +++ b/apps/sim/content/library/how-to-build-shopify-ai-agent-orders-inventory/index.mdx @@ -0,0 +1,239 @@ +--- +slug: how-to-build-shopify-ai-agent-orders-inventory +title: 'How to Build a Shopify AI Agent for Orders and Inventory' +seoTitle: 'Build a Shopify AI Agent for Orders and Inventory' +description: 'Build a Shopify AI agent that monitors inventory, looks up orders, drafts customer updates, and requires human approval before sending.' +date: 2026-11-12 +updated: 2026-11-12 +authors: + - andrew +readingTime: 10 +tags: [AI Agents, Ecommerce, Shopify, Automation, Sim] +ogImage: /library/how-to-build-shopify-ai-agent-orders-inventory/cover.jpg +draft: false +faq: + - q: "Can Sim connect to Shopify for order and inventory automation?" + a: "Sim connects to Shopify through its native Shopify integration and can combine Shopify actions with schedules, AI model steps, conditions, Slack alerts, human approval, and workflow-run inspection." + - q: "Can Sim flag low Shopify stock automatically?" + a: "Sim can run scheduled inventory checks, compare available quantities with deterministic thresholds, and alert an operations team when Shopify stock is low." + - q: "Can Sim update Shopify inventory automatically?" + a: "Sim can place a documented Shopify inventory-write action inside a controlled workflow, but the quantity should come from a trusted source with exact inventory item and location identifiers." + - q: "Can Sim look up a Shopify order and draft a customer response?" + a: "Sim can retrieve the authoritative Shopify order record and use selected fields to draft a grounded customer update for review." + - q: "Can Sim require approval before sending a Shopify customer update?" + a: "Sim can pause the workflow with Human in the Loop, collect an approval decision, and use a downstream Condition block to route approved and rejected drafts." + - q: "Does Sim's Human in the Loop block approve or reject a Shopify action by itself?" + a: "Sim's Human in the Loop block collects the reviewer's form response, while a downstream Condition block must evaluate the approve-or-reject field and control what happens next." + - q: "Should an AI model decide whether Shopify inventory is low?" + a: "Sim should use a deterministic condition rather than an AI model to compare Shopify inventory quantities with low-stock thresholds." + - q: "Should a Shopify order status agent be allowed to issue refunds?" + a: "Sim should keep refunds outside a basic order status agent and place refund actions in a separate workflow with restricted credentials and explicit approval." + - q: "When does Sim fit better than Shopify Flow for an AI agent?" + a: "Sim fits workflows that need AI reasoning, model choice, cross-system steps, and built-in human review, while Shopify Flow fits deterministic automation that remains primarily inside Shopify." + - q: "When does Sim fit better than n8n for a Shopify AI agent?" + a: "Sim fits Shopify workflows centered on model steps, structured outputs, and built-in human review, while n8n fits technical teams building broad self-hosted automations." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License, which is not an OSI-approved open-source license." + - q: "Is Sim open source?" + a: "Sim uses an open-core model. Its core is open source, while enterprise code has separate terms and requires an active Enterprise subscription for production use." + - q: "Can Sim be self-hosted for Shopify workflows?" + a: "Sim supports self-hosting for teams that want to run Shopify agent workflows in their own environment, subject to Shopify connectivity and credential requirements." + - q: "Can a self-hosted Sim Shopify agent use local models?" + a: "Sim supports Ollama, vLLM, LM Studio, and LiteLLM-compatible local model setups on self-hosted deployments without requiring Sim Enterprise solely for local-model access." + - q: "Can Sim send Shopify low-stock alerts to Slack?" + a: "Sim can send structured Shopify inventory alerts to Slack after a scheduled check and deterministic threshold condition." + - q: "What data should a Shopify low-stock alert include?" + a: "Sim should include the product, variant, location, available quantity, threshold, and exact Shopify identifiers needed for investigation." + - q: "How often should a Shopify AI agent check inventory?" + a: "Sim should check Shopify inventory at an interval based on sales velocity, warehouse synchronization, catalog size, Shopify constraints, and the operations team's response time." + - q: "Can a Shopify AI agent handle multiple inventory locations?" + a: "Sim can evaluate Shopify inventory by location when the workflow preserves each location identifier and applies the correct threshold to each record." + - q: "How do you stop a Shopify AI agent from hallucinating order status?" + a: "Sim should retrieve status from Shopify, restrict the model to supplied fields, prohibit unsupported claims, and require approval before sending the response." + - q: "What happens when Sim cannot find a Shopify order?" + a: "Sim should route a missing or ambiguous Shopify order to a support queue instead of asking the model to infer customer or fulfillment data." + - q: "Can Sim automate Shopify returns?" + a: "Sim can orchestrate a returns workflow across connected systems, but refund, restocking, and shipping actions should use deterministic rules and explicit approval for financial or irreversible steps." + - q: "What is the safest first Shopify AI agent to build?" + a: "A safe first Shopify agent in Sim is a read-only order lookup or low-stock alert workflow because it delivers operational value without granting broad write permissions." +--- + +## TL;DR + +Sim fits teams building a Shopify AI agent that checks inventory, flags low stock in Slack, looks up orders, and pauses customer-facing updates for human approval. The safest design uses deterministic Shopify data for quantities and order status, an AI model only for drafting language, and an explicit approval route before any message is sent. + +## What AI agent tools connect to Shopify for order and inventory automation? + +Sim, n8n, Zapier, Make, and Shopify Flow can support Shopify order and inventory automation. The right fit depends on whether the workflow needs AI-generated responses, human approval, self-hosting, broad app automation, or Shopify-native rules. + +| Platform | Shopify capability | Scheduling and inspection | Fits | +|---|---|---|---| +| Sim | Native Shopify actions for order lookup, inventory reads, and controlled inventory adjustments | Schedule triggers, Human in the Loop with a downstream approval condition, and detailed workflow run logs | AI agents that combine Shopify data, model steps, approvals, and team alerts | +| [n8n](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.shopify) | A Shopify node for order and product operations, with HTTP requests available for other API operations | A [Schedule Trigger](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.scheduletrigger) and [execution history](https://docs.n8n.io/build/understand-workflows/understand-executions) | Technical teams that want self-hosted, general-purpose automation | +| [Zapier](https://zapier.com/apps/shopify/integrations) | Shopify triggers and actions for common commerce events | [Schedule by Zapier](https://help.zapier.com/hc/en-us/articles/8496288648461-Schedule-Zap-workflows-to-run-at-specific-intervals) and [Zap run details](https://help.zapier.com/hc/en-us/articles/20512774106125-View-specific-Zap-run-details) | Fast SaaS automation across a broad app catalog | +| [Make](https://www.make.com/integrations/shopify/stripe) | Shopify triggers and actions in visual scenarios | [Scenario scheduling](https://help.make.com/schedule-a-scenario) and [scenario history](https://help.make.com/scenario-history) | Visual, data-heavy commerce automations with branching | +| [Shopify Flow](https://help.shopify.com/en/manual/shopify-flow) | Shopify-native triggers, conditions, and actions | A [Scheduled time trigger](https://help.shopify.com/en/manual/shopify-flow/reference/triggers/scheduled-time) and workflow activity inside Shopify | Deterministic automation that stays primarily inside Shopify | + +As of November 2026, the vendors document different licensing and workflow metering models: + +- [Sim's core uses the Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Sim supports self-hosted deployments. +- n8n's self-hosted community version uses its [Sustainable Use License](https://docs.n8n.io/n8n-community-license/community-license/license-faq), and [n8n Cloud pricing is based on workflow executions](https://n8n.io/pricing/). +- Zapier measures Zap usage in [successful action tasks](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier). +- Make documents [credits as its billing unit](https://help.make.com/credits), with non-AI app operations generally consuming one credit each. +- [Shopify Flow is a free app on eligible Shopify plans](https://help.shopify.com/en/manual/shopify-flow), rather than a separately billed agent builder. + +For a broader ecommerce comparison covering support, returns, merchandising, and back-office operations, see [6 Best AI Agent Options for Ecommerce Operations in 2026](https://www.sim.ai/library/best-ai-agents-ecommerce-operations-2026). + +## What tools can update Shopify inventory and flag low stock automatically? + +Sim handles this use case by combining scheduled Shopify inventory checks, deterministic threshold rules, optional inventory-adjustment steps, Slack alerts, and inspectable workflow runs. Sim's [Shopify integration](https://docs.sim.ai/integrations/shopify) includes inventory reads and writes alongside order, product, customer, location, and fulfillment operations. + +The important distinction is between checking inventory and changing inventory. A low-stock monitor can safely remain read-only, while an inventory adjustment should run only when a trusted source supplies the exact inventory item, location, quantity change, and reason. + +A reliable design separates the two paths: + +1. The monitoring path reads Shopify inventory and alerts the operations team when available stock crosses a threshold. +2. The update path receives a validated stock event from a warehouse, ERP, returns system, or approved operator before changing Shopify. +3. The workflow records the inputs and outputs so the team can investigate discrepancies. + +Sim should not ask an AI model to invent quantities or decide which Shopify variant to update from an ambiguous product name. Use exact IDs and deterministic conditions for inventory writes, reserving the model for tasks such as summarizing an alert or explaining the operational impact. + +## How do you build a scheduled Shopify low-stock monitor in Sim? + +Build the scheduled low-stock monitor as a read-only workflow that retrieves inventory, evaluates a fixed threshold, and alerts the operations team in Slack. + +### How should the Shopify low-stock workflow start? + +Start the low-stock workflow with a Schedule trigger set to the monitoring interval the store can support operationally. + +A small store may check once or twice per day, while a high-volume operation may need more frequent checks. The interval should account for catalog size, warehouse synchronization frequency, stock velocity, and how quickly the team can act on an alert. + +### How should Sim check Shopify inventory? + +Use Sim's native Shopify inventory-reading operation and preserve Shopify's inventory item and location identifiers throughout the workflow. Shopify models an [inventory level as an item at a specific location](https://shopify.dev/docs/api/admin-graphql/latest/queries/inventorylevel), so location-level evaluation avoids hiding a fulfillment shortage behind stock held elsewhere. + +Configure Shopify List Inventory Items to return the inventory records needed for the monitored catalog. The operation returns 50 inventory items by default, so repeat it with `pageInfo.endCursor` in the `after` field while `pageInfo.hasNextPage` is true. Each inventory item includes up to 10 locations; when `inventoryLevelsPageInfo.hasNextPage` is true, use Shopify Get Inventory Level with the inventory item ID, repeat it with its `pageInfo.endCursor` in `after`, and evaluate every returned location record. + +Normalize each record into fields such as: + +- Product title +- Variant title +- Inventory item ID +- Location name +- Location ID +- Available quantity +- Low-stock threshold +- Shopify admin reference + +### How should Sim decide whether Shopify stock is low? + +Use a deterministic Condition block to compare available quantity with the store's threshold. + +For example, route a variant to the alert branch when available quantity is less than or equal to its threshold. Keep the threshold in a maintained data source when different products need different reorder points. Otherwise, a workflow variable can hold one shared threshold. + +The AI model should not make this comparison. Inventory quantities and thresholds are structured values, so a deterministic rule is more predictable and easier to inspect. + +### How should Sim alert the operations team about low Shopify stock? + +Send a structured Slack alert containing the exact variant, location, available quantity, and threshold that triggered the workflow. + +A useful alert template is: + +```text +Low Shopify stock detected +Product: {{product_title}} +Variant: {{variant_title}} +Location: {{location_name}} +Available: {{available_quantity}} +Threshold: {{threshold}} +Inventory item ID: {{inventory_item_id}} +``` + +Group alerts into one digest when a large catalog could produce excessive messages. Send an immediate message only for urgent conditions such as zero available units or a high-priority product crossing its safety-stock threshold. + +For more trigger patterns, see [Best AI Agent Platforms for Webhook, Schedule, and Event Triggers](https://www.sim.ai/library/best-ai-agent-platforms-webhook-schedule-event-triggers). + +## How do you build a Shopify order status agent with human approval? + +Build a Shopify order status agent by accepting an order identifier, retrieving the authoritative Shopify record, drafting a customer update, and pausing the run for human review. + +### How should the Shopify order status agent receive a request? + +Receive an exact Shopify order ID or order number together with the minimum customer information needed to validate the request. Shopify Get Order requires a GraphQL ID such as `gid://shopify/Order/123456789`, so an order number such as `#1001` must first be resolved with Shopify List Orders and its returned order ID passed to Shopify Get Order. + +The request can originate from an internal form, a support workflow, Chat, or another authenticated system. Do not let a customer retrieve an order by supplying only a common name or email address. Require an appropriate identity check before exposing order details. + +### How should Sim look up the Shopify order? + +Use Sim's Shopify order lookup operation and treat Shopify as the source of truth for payment, fulfillment, cancellation, and shipment fields. Shopify's [order query retrieves an order by ID and exposes financial and fulfillment information](https://shopify.dev/docs/api/admin-graphql/latest/queries/order). + +Pass the exact Shopify GraphQL order ID into Shopify Get Order. If the request supplies an order number, query Shopify List Orders for that number, verify the match, and pass the returned `id` into Shopify Get Order. If no matching order is found, route the run to a support queue instead of asking the model to infer an order or fabricate a status. + +### How should Sim draft the customer update? + +Give the model only the verified Shopify fields needed to write a concise status update. + +A suitable model instruction is: + +```text +Draft a short customer update using only the supplied Shopify order data. Do not promise a delivery date unless the data contains one. Do not claim an item has shipped unless Shopify reports it as fulfilled or shipped. If the status is ambiguous, say that a support specialist is reviewing the order. +``` + +Ask the model for structured output with fields such as `subject`, `message`, `statusSummary`, and `escalationNeeded`. Structured output makes the approval screen and downstream routing more predictable. + +### How does human approval work in a Sim Shopify agent? + +Sim's Human in the Loop block pauses the workflow and resumes it with the reviewer's submitted form fields. + +Add fields for the edited customer message, an approve-or-reject choice, and an optional reviewer note. The Human in the Loop block does not enforce the choice by itself, so place a Condition block immediately afterward and route on the submitted approval value. + +The approved branch can pass the message to the store's customer communication system. The rejected branch should stop the customer-facing path and send the request to the support team with the reviewer's note. + +For a deeper comparison of approval patterns, see [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows). + +## How do you prevent a Shopify AI agent from sending the wrong order update? + +Prevent incorrect Shopify order updates by grounding every factual claim in Shopify data, requiring identity checks, using structured output, and placing a human approval condition before delivery. + +Use these controls: + +1. Retrieve the order from Shopify instead of relying on customer-supplied status text. +2. Pass only the relevant order fields to the model. +3. Prohibit invented shipment dates, refunds, discounts, and fulfillment claims in the model instruction. +4. Require structured output so missing fields are detectable. +5. Pause for human review before any customer-facing message. +6. Route rejected or ambiguous drafts to support. +7. Inspect the workflow run when a customer disputes a message. +8. Limit Shopify credentials to the permissions the workflow actually needs. + +A read-only order status agent should not receive inventory-write, refund, or cancellation permissions. Separate higher-risk actions into dedicated workflows with narrower credentials and explicit approvals. + +## Should a Shopify AI agent automatically update inventory? + +A Shopify AI agent should automatically adjust inventory only when the quantity change comes from a trusted system and the workflow has exact inventory item, location, and delta inputs. + +Warehouse receipts, approved cycle counts, completed returns, and ERP synchronization events can be suitable sources. Free-form customer messages, model estimates, and ambiguous product names are not suitable sources. + +For a write workflow, validate the event, reject missing identifiers, record the previous and requested quantities, require approval for unusual changes, run Shopify's documented [inventory adjustment operation](https://shopify.dev/docs/api/admin-graphql/latest/mutations/inventoryAdjustQuantities), and confirm the resulting quantity. A separate low-stock monitor can then detect whether the adjusted quantity crossed an alert threshold. + +## What should ecommerce teams log for Shopify order and inventory agents? + +Retain enough workflow-run context to explain what triggered the agent, which Shopify record it used, which condition fired, and what action followed. + +Useful diagnostic fields include the workflow version, trigger time, Shopify order or inventory item ID, location ID, quantity before and after a write, threshold, approval decision, reviewer identity where appropriate, downstream message identifier, and error output. Avoid copying unnecessary customer data into prompts, alerts, or logs. + +Teams operating important commerce workflows should also monitor failed Shopify requests, repeated retries, missing identifiers, rejected approvals, unusual inventory changes, and message-delivery failures. See [What Is AI Agent Observability? Traces, Metrics, and Evals Explained](https://www.sim.ai/library/ai-agent-observability) for the broader monitoring model. + +## What platform fits ecommerce teams automating order and returns handling? + +Sim fits ecommerce teams that need customizable AI reasoning, native business-system actions, human approval, and inspectable multi-step workflows for orders and returns. + +Shopify Flow often fits deterministic Shopify-only rules. n8n, Zapier, and Make fit teams whose main requirement is broad app-to-app automation. Returns handling should be treated as a higher-risk workflow because it can affect refunds, inventory, shipping labels, and customer communications. Use separate approval gates for financial or irreversible actions. + +## What platform fits a small store that needs customer answers and order updates? + +Sim fits a small ecommerce store that wants one agent to retrieve Shopify order data, draft grounded answers, and require approval before sending sensitive updates. + +Start with a narrow order-status workflow rather than an unrestricted support agent. Let the agent answer only from verified Shopify fields and approved store policies, escalate exceptions to a person, and keep refunds, cancellations, address changes, and inventory writes behind separate controls. + +[Build your Shopify agent in Sim](https://www.sim.ai) with a read-only order lookup or low-stock monitor first, then add tightly scoped write actions as the operating process matures. diff --git a/apps/sim/ee/access-requests/lib/targets.ts b/apps/sim/ee/access-requests/lib/targets.ts index be0cf1174ff..a34e87b2fe9 100644 --- a/apps/sim/ee/access-requests/lib/targets.ts +++ b/apps/sim/ee/access-requests/lib/targets.ts @@ -98,6 +98,7 @@ const FEATURE_PARENTS: Partial< disableKnowledgeBaseCreation: ['hideKnowledgeBaseTab'], disableKnowledgeBaseFileUpload: ['hideKnowledgeBaseTab'], disableKnowledgeBaseExport: ['hideKnowledgeBaseTab'], + disableComputerUse: ['hideCopilot'], disableTableCreation: ['hideTablesTab'], disableTableExport: ['hideTablesTab'], disableBulkFileDownload: ['hideFilesTab'], diff --git a/apps/sim/executor/handlers/agent/handler.test.ts b/apps/sim/executor/handlers/agent/handler.test.ts index e1b1a3cabc4..85c92e38033 100644 --- a/apps/sim/executor/handlers/agent/handler.test.ts +++ b/apps/sim/executor/handlers/agent/handler.test.ts @@ -6,6 +6,10 @@ import { schemaMock, setEnvFlags, } from '@sim/testing' +import { + llmBedrockCredentialMock, + llmBedrockCredentialMockFns, +} from '@sim/testing/mocks/llm-bedrock-credential.mock' import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { permissionCheckMock, @@ -61,6 +65,8 @@ mockGetBlock.mockReturnValue(undefined) const mockImportWorkspaceFileSecretProvenanceForModelView = workspaceFileSecretProvenanceMockFns.mockImportWorkspaceFileSecretProvenanceForModelView const mockValidateModelProvider = permissionCheckMockFns.mockValidateModelProvider +const mockResolveBedrockCredentialSession = + llmBedrockCredentialMockFns.mockResolveBedrockCredentialSession mockImportWorkspaceFileSecretProvenanceForModelView.mockResolvedValue(true) providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('mock-provider') providersUtilsMockFns.mockIsDeepResearchModel.mockImplementation((model: string) => @@ -75,6 +81,8 @@ const { mockDiscoverMcpServerToolsAsExecutor, mockOpenAgentTurnSession } = vi.ho mockOpenAgentTurnSession: vi.fn().mockResolvedValue(undefined), })) +vi.mock('@/lib/internal/llm/bedrock-credential', () => llmBedrockCredentialMock) + vi.mock('@/lib/memory/agent-turn-session', () => ({ openAgentTurnSession: mockOpenAgentTurnSession, })) @@ -817,6 +825,7 @@ describe('AgentBlockHandler', () => { if (model.startsWith('gpt')) return 'openai' if (model.startsWith('claude')) return 'anthropic' if (model.startsWith('vertex/')) return 'vertex' + if (model.startsWith('bedrock/')) return 'bedrock' if (model === 'blacklisted-model') throw new Error('provider blacklisted') return 'openai' } @@ -1134,6 +1143,91 @@ describe('AgentBlockHandler', () => { expect(sameProvider.azureEndpoint).toBe('https://azure.example.com') }) + it("signs a Bedrock call with the selected AWS credential's session for the run's user", async () => { + const session = { + credentialId: 'aws-cred', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + } + mockResolveBedrockCredentialSession.mockResolvedValueOnce(session) + mockExecuteProviderRequest.mockResolvedValueOnce(providerResponse('bedrock/claude')) + + await handler.execute(mockContext, mockBlock, { + ...baseInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + bedrockRegion: 'us-west-2', + }) + + expect(mockResolveBedrockCredentialSession).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: 'aws-cred', + actingUserId: mockContext.userId, + workflowId: mockContext.workflowId, + workspaceId: mockContext.workspaceId, + }) + ) + const [providerId, request] = mockExecuteProviderRequest.mock.calls[0] + expect(providerId).toBe('bedrock') + expect(request.bedrockSession).toEqual(session) + expect(request.bedrockRegion).toBe('us-west-2') + }) + + it('fails a Bedrock call whose AWS credential cannot be used instead of sending it', async () => { + mockResolveBedrockCredentialSession.mockRejectedValueOnce( + new Error('Not authorized to use this AWS credential') + ) + + await expect( + handler.execute(mockContext, mockBlock, { + ...baseInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + }) + ).rejects.toThrow('Not authorized to use this AWS credential') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('fails a Bedrock call set to a saved AWS credential when the credential resolves blank', async () => { + await expect( + handler.execute(mockContext, mockBlock, { + ...baseInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: ' ', + }) + ).rejects.toThrow('Select an AWS credential for Bedrock') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('keeps the AWS credential off a fallback on another provider', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce(providerResponse('gpt-4o-mini')) + mockResolveBedrockCredentialSession.mockResolvedValueOnce({ + credentialId: 'aws-cred', + accessKeyId: 'AKIASAVEDKEY00000001', + secretAccessKey: 'saved-secret', + }) + + await handler.execute(mockContext, mockBlock, { + ...baseInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + fallbackModels: [{ model: 'gpt-4o-mini' }], + }) + + const [fallbackProvider, fallbackRequest] = mockExecuteProviderRequest.mock.calls[1] + expect(fallbackProvider).toBe('openai') + expect(fallbackRequest.bedrockSession).toBeUndefined() + expect(JSON.stringify(fallbackRequest)).not.toMatch(/aws-cred|saved-secret/) + expect(mockResolveBedrockCredentialSession).toHaveBeenCalledTimes(1) + }) + it('re-resolves tuning for the fallback: row value wins, caps clamp, undeclared values drop', async () => { const fallbackCap = getModelCapabilities('gpt-5.4-mini')?.maxOutputTokens expect(fallbackCap).toEqual(expect.any(Number)) diff --git a/apps/sim/executor/handlers/agent/handler.ts b/apps/sim/executor/handlers/agent/handler.ts index 96a953799ee..03da301096e 100644 --- a/apps/sim/executor/handlers/agent/handler.ts +++ b/apps/sim/executor/handlers/agent/handler.ts @@ -93,6 +93,10 @@ import type { } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' import { stringifyJSON } from '@/executor/utils/json' +import { + BLOCK_MODEL_CREDENTIAL_FIELDS, + resolveBlockModelCredentials, +} from '@/executor/utils/model-credentials' import { assertFallbackFamilyCredentials, getModelFallbacks, @@ -108,7 +112,6 @@ import type { ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' import { annotateDuplicateToolBindings } from '@/executor/utils/tool-binding-labels' -import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { executeProviderRequest } from '@/providers' import { formatAttachmentSizes, @@ -160,6 +163,7 @@ const AGENT_RAW_PROVIDER_ERROR_INPUT_PATHS: readonly ResolvedSecretInputPath[] = ['bedrockAccessKeyId'], ['bedrockSecretKey'], ['bedrockRegion'], + ['bedrockCredential'], ['reasoningEffort'], ['verbosity'], ['thinkingLevel'], @@ -2706,7 +2710,10 @@ export class AgentBlockHandler implements BlockHandler { inputs = { ...(sameProvider ? config.modelInputs - : omit(config.modelInputs, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS, 'vertexCredential'])), + : omit(config.modelInputs, [ + ...PROVIDER_FAMILY_CREDENTIAL_FIELDS, + ...BLOCK_MODEL_CREDENTIAL_FIELDS, + ])), apiKey: resolveFallbackApiKey({ candidate, configuredModel: config.configuredModel, @@ -2920,6 +2927,8 @@ export class AgentBlockHandler implements BlockHandler { bedrockAccessKeyId: inputs.bedrockAccessKeyId, bedrockSecretKey: inputs.bedrockSecretKey, bedrockRegion: inputs.bedrockRegion, + bedrockCredential: inputs.bedrockCredential, + bedrockAuthMode: inputs.bedrockAuthMode, responseFormat, workflowId: ctx.workflowId, workspaceId: ctx.workspaceId, @@ -2980,18 +2989,12 @@ export class AgentBlockHandler implements BlockHandler { const model = providerRequest.model try { - let finalApiKey: string | undefined = providerRequest.apiKey - - if (providerId === 'vertex' && providerRequest.vertexCredential) { - finalApiKey = await resolveVertexCredential({ - credentialId: providerRequest.vertexCredential, - actingUserId: ctx.userId, - workspaceId: ctx.workspaceId, - workflowId: ctx.workflowId, - executionId: ctx.executionId, - callerLabel: 'vertex-agent', - }) - } + const credentials = await resolveBlockModelCredentials( + ctx, + providerId, + providerRequest, + 'vertex-agent' + ) const { blockData, blockNameMapping } = collectBlockData(ctx) const agentMemoryRetrieval = agentConversation?.memoryId @@ -3014,7 +3017,7 @@ export class AgentBlockHandler implements BlockHandler { : providerRequest.tools, temperature: providerRequest.temperature, maxTokens: providerRequest.maxTokens, - apiKey: finalApiKey, + apiKey: credentials.apiKey ?? providerRequest.apiKey, azureEndpoint: providerRequest.azureEndpoint, azureApiVersion: providerRequest.azureApiVersion, vertexProject: providerRequest.vertexProject, @@ -3022,6 +3025,7 @@ export class AgentBlockHandler implements BlockHandler { bedrockAccessKeyId: providerRequest.bedrockAccessKeyId, bedrockSecretKey: providerRequest.bedrockSecretKey, bedrockRegion: providerRequest.bedrockRegion, + bedrockSession: credentials.bedrockSession, responseFormat: providerRequest.responseFormat, workflowId: providerRequest.workflowId, workspaceId: ctx.workspaceId, diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index a092fee6959..4b218e90be7 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -46,6 +46,10 @@ export interface AgentInputs { bedrockAccessKeyId?: string bedrockSecretKey?: string bedrockRegion?: string + /** A saved AWS credential ID; Bedrock signs with it instead of inline keys. */ + bedrockCredential?: string + /** `'credential'` when the block signs Bedrock calls with {@link bedrockCredential}. */ + bedrockAuthMode?: string reasoningEffort?: string verbosity?: string thinkingLevel?: string diff --git a/apps/sim/executor/handlers/evaluator/handler.test.ts b/apps/sim/executor/handlers/evaluator/handler.test.ts index c181b4c1248..c443c1efcab 100644 --- a/apps/sim/executor/handlers/evaluator/handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/handler.test.ts @@ -2,6 +2,11 @@ import { credentialsAccessMock, credentialsAccessMockFns, } from '@sim/testing/mocks/credentials-access.mock' +import { + llmBedrockCredentialMock, + llmBedrockCredentialMockFns, + MockBedrockCredentialError, +} from '@sim/testing/mocks/llm-bedrock-credential.mock' import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' import '@sim/testing/mocks/executor' @@ -12,6 +17,8 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({ mockResolveAutoModel: vi.fn(), })) +vi.mock('@/lib/internal/llm/bedrock-credential', () => llmBedrockCredentialMock) + vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) @@ -52,6 +59,8 @@ credentialsAccessMockFns.mockGetCredentialActorContext.mockResolvedValue({ }) const mockCheckWorkspaceAccess = permissionsMockFns.mockCheckWorkspaceAccess +const mockResolveBedrockCredentialSession = + llmBedrockCredentialMockFns.mockResolveBedrockCredentialSession const mockGetProviderFromModel = getProviderFromModel as Mock const mockExecuteProviderRequest = executeProviderRequest as Mock @@ -176,6 +185,136 @@ describe('EvaluatorBlockHandler', () => { expect(fallback.apiKey).toBeUndefined() }) + it("signs a Bedrock evaluation with the selected AWS credential's session", async () => { + const session = { + credentialId: 'aws-cred', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + } + mockGetProviderFromModel.mockReturnValue('bedrock') + mockResolveBedrockCredentialSession.mockResolvedValueOnce(session) + + await handler.execute(mockContext, mockBlock, { + ...admissionInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + }) + + expect(mockResolveBedrockCredentialSession).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'aws-cred', actingUserId: 'test-user' }) + ) + expect(mockExecuteProviderRequest.mock.calls[0][1].bedrockSession).toEqual(session) + }) + + it('fails a Bedrock evaluation set to a saved AWS credential when none resolves', async () => { + mockGetProviderFromModel.mockReturnValue('bedrock') + + await expect( + handler.execute(mockContext, mockBlock, { + ...admissionInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + }) + ).rejects.toThrow('Select an AWS credential for Bedrock') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('keeps the AWS credential session off a fallback on another provider', async () => { + mockGetProviderFromModel.mockImplementation((model: string) => + model.startsWith('claude') ? 'anthropic' : 'bedrock' + ) + mockResolveBedrockCredentialSession.mockResolvedValueOnce({ + credentialId: 'aws-cred', + accessKeyId: 'AKIASAVEDKEY00000001', + secretAccessKey: 'saved-secret', + }) + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce({ + content: '{"score1":7}', + model: 'claude-sonnet-5', + tokens: { input: 12, output: 3, total: 15 }, + cost: 0, + }) + + await handler.execute(mockContext, mockBlock, { + ...admissionInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + + const [fallbackProvider, fallbackRequest] = mockExecuteProviderRequest.mock.calls[1] + expect(fallbackProvider).toBe('anthropic') + expect(fallbackRequest.bedrockSession).toBeUndefined() + expect(JSON.stringify(fallbackRequest)).not.toContain('saved-secret') + }) + + it('scores with a fallback model when the saved AWS credential is unavailable on the final try', async () => { + mockGetProviderFromModel.mockImplementation((model: string) => + model.startsWith('claude') ? 'anthropic' : 'bedrock' + ) + mockResolveBedrockCredentialSession.mockRejectedValueOnce( + new MockBedrockCredentialError('AWS STS is unavailable', { reason: 'unavailable' }) + ) + mockExecuteProviderRequest.mockResolvedValueOnce({ + content: '{"score1":7}', + model: 'claude-sonnet-5', + tokens: { input: 12, output: 3, total: 15 }, + cost: 0, + }) + mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() + mockContext.blockLogs = [ + { + blockId: mockBlock.id, + startedAt: '', + endedAt: '', + durationMs: 0, + success: false, + executionOrder: 1, + }, + ] + + const output = await handler.execute( + mockContext, + mockBlock, + { + ...admissionInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + fallbackModels: [{ model: 'claude-sonnet-5' }], + }, + { nodeId: mockBlock.id, retry: { attempt: 2, maxTries: 2, isFinalTry: true } } + ) + + expect(output).toMatchObject({ model: 'claude-sonnet-5', score1: 7 }) + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + expect(mockExecuteProviderRequest.mock.calls[0][0]).toBe('anthropic') + expect(mockContext.blockLogs[0].modelFallbacks).toEqual(['bedrock/claude']) + }) + + it('fails a Bedrock evaluation whose AWS credential cannot be used instead of sending it', async () => { + mockGetProviderFromModel.mockReturnValue('bedrock') + mockResolveBedrockCredentialSession.mockRejectedValueOnce( + new Error('Not authorized to use this AWS credential') + ) + mockExecuteProviderRequest.mockClear() + + await expect( + handler.execute(mockContext, mockBlock, { + ...admissionInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + }) + ).rejects.toThrow('Not authorized to use this AWS credential') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + it('refuses to reach the provider without an execution subject', async () => { mockContext.userId = undefined diff --git a/apps/sim/executor/handlers/evaluator/handler.ts b/apps/sim/executor/handlers/evaluator/handler.ts index 68118cd0e31..7eddaf327f5 100644 --- a/apps/sim/executor/handlers/evaluator/handler.ts +++ b/apps/sim/executor/handlers/evaluator/handler.ts @@ -18,7 +18,6 @@ import type { ResolvedSecretInputPath, ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' -import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { resolveProxiedModelCost } from '@/providers/cost-policy' import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' import type { ProviderRequest } from '@/providers/types' @@ -50,6 +49,8 @@ export class EvaluatorBlockHandler implements BlockHandler { bedrockAccessKeyId: inputs.bedrockAccessKeyId, bedrockSecretKey: inputs.bedrockSecretKey, bedrockRegion: inputs.bedrockRegion, + bedrockCredential: inputs.bedrockCredential, + bedrockAuthMode: inputs.bedrockAuthMode, } let systemPromptObj: { systemPrompt: string; responseFormat: any } = { @@ -178,18 +179,6 @@ export class EvaluatorBlockHandler implements BlockHandler { await validateModelProvider(ctx.userId, ctx.workspaceId, model, ctx) const providerId = getProviderFromModel(model) - let finalApiKey: string | undefined = evaluatorConfig.apiKey - if (providerId === 'vertex' && evaluatorConfig.vertexCredential) { - finalApiKey = await resolveVertexCredential({ - credentialId: evaluatorConfig.vertexCredential, - actingUserId: ctx.userId, - workspaceId: ctx.workspaceId, - workflowId: ctx.workflowId, - executionId: ctx.executionId, - callerLabel: 'vertex-evaluator', - }) - } - try { const providerRequest: ProviderRequest = { model, @@ -204,7 +193,7 @@ export class EvaluatorBlockHandler implements BlockHandler { ]), temperature: EVALUATOR.DEFAULT_TEMPERATURE, - apiKey: finalApiKey, + apiKey: evaluatorConfig.apiKey, azureEndpoint: inputs.azureEndpoint, azureApiVersion: inputs.azureApiVersion, vertexProject: evaluatorConfig.vertexProject, @@ -226,6 +215,7 @@ export class EvaluatorBlockHandler implements BlockHandler { providerId, request: providerRequest, resolvedSecretTraceRegistry: modelInputProjection.registry, + credentials: { inputs: evaluatorConfig, callerLabel: 'vertex-evaluator' }, }) const parsedContent = this.extractJSONFromResponse( diff --git a/apps/sim/executor/handlers/router/handler.test.ts b/apps/sim/executor/handlers/router/handler.test.ts index efd6684a7b9..2230532f119 100644 --- a/apps/sim/executor/handlers/router/handler.test.ts +++ b/apps/sim/executor/handlers/router/handler.test.ts @@ -2,6 +2,11 @@ import { credentialsAccessMock, credentialsAccessMockFns, } from '@sim/testing/mocks/credentials-access.mock' +import { + llmBedrockCredentialMock, + llmBedrockCredentialMockFns, + MockBedrockCredentialError, +} from '@sim/testing/mocks/llm-bedrock-credential.mock' import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' import '@sim/testing/mocks/executor' @@ -18,6 +23,8 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({ mockResolveAutoModel: vi.fn(), })) +vi.mock('@/lib/internal/llm/bedrock-credential', () => llmBedrockCredentialMock) + vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) @@ -59,6 +66,8 @@ credentialsAccessMockFns.mockGetCredentialActorContext.mockResolvedValue({ }) const mockCheckWorkspaceAccess = permissionsMockFns.mockCheckWorkspaceAccess +const mockResolveBedrockCredentialSession = + llmBedrockCredentialMockFns.mockResolveBedrockCredentialSession const mockGenerateRouterPrompt = generateRouterPrompt as Mock const mockGenerateRouterV2Prompt = generateRouterV2Prompt as Mock @@ -178,6 +187,90 @@ describe('RouterBlockHandler', () => { }) }) + it("signs a Bedrock routing call with the selected AWS credential's session", async () => { + const session = { + credentialId: 'aws-cred', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + } + mockGetProviderFromModel.mockReturnValue('bedrock') + mockResolveBedrockCredentialSession.mockResolvedValueOnce(session) + + await handler.execute(mockContext, mockBlock, { + prompt: 'Pick a destination', + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + }) + + expect(mockResolveBedrockCredentialSession).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'aws-cred', actingUserId: 'test-user' }) + ) + expect(mockExecuteProviderRequest.mock.calls[0][1].bedrockSession).toEqual(session) + }) + + it('fails a Bedrock routing call set to a saved AWS credential when none resolves', async () => { + mockGetProviderFromModel.mockReturnValue('bedrock') + + await expect( + handler.execute(mockContext, mockBlock, { + prompt: 'Pick a destination', + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: '', + }) + ).rejects.toThrow('Select an AWS credential for Bedrock') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('routes with a fallback model when the saved AWS credential is unavailable on the final try', async () => { + mockGetProviderFromModel.mockImplementation((model: string) => + model.startsWith('claude') ? 'anthropic' : 'bedrock' + ) + mockResolveBedrockCredentialSession.mockRejectedValueOnce( + new MockBedrockCredentialError('AWS STS is unavailable', { reason: 'unavailable' }) + ) + mockExecuteProviderRequest.mockResolvedValueOnce({ + content: 'target-block-1', + model: 'claude-sonnet-5', + tokens: { input: 10, output: 2, total: 12 }, + cost: 0, + }) + mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() + mockContext.blockLogs = [ + { + blockId: mockBlock.id, + startedAt: '', + endedAt: '', + durationMs: 0, + success: false, + executionOrder: 1, + }, + ] + + const output = await handler.execute( + mockContext, + mockBlock, + { + prompt: 'Pick a destination', + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + fallbackModels: [{ model: 'claude-sonnet-5' }], + }, + { nodeId: mockBlock.id, retry: { attempt: 2, maxTries: 2, isFinalTry: true } } + ) + + expect(output).toMatchObject({ + model: 'claude-sonnet-5', + selectedPath: { blockId: 'target-block-1' }, + }) + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + expect(mockExecuteProviderRequest.mock.calls[0][0]).toBe('anthropic') + expect(mockContext.blockLogs[0].modelFallbacks).toEqual(['bedrock/claude']) + }) + it('selects the same legacy destination when a fallback provider answers', async () => { mockExecuteProviderRequest .mockRejectedValueOnce(new Error('overloaded')) @@ -514,6 +607,33 @@ describe('RouterBlockHandler V2', () => { }) }) + it("signs a Bedrock Router V2 call with the selected AWS credential's session", async () => { + const session = { + credentialId: 'aws-cred', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + } + mockGetProviderFromModel.mockReturnValue('bedrock') + mockResolveBedrockCredentialSession.mockResolvedValueOnce(session) + mockExecuteProviderRequest.mockResolvedValueOnce({ + content: '{"route":"route-support","reasoning":"Needs assistance"}', + model: 'bedrock/claude', + tokens: { input: 10, output: 2, total: 12 }, + cost: 0, + }) + + await handler.execute(mockContext, mockRouterV2Block, { + context: 'Help me', + model: 'bedrock/claude', + routes: [{ id: 'route-support', title: 'Support', value: 'Needs help' }], + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + }) + + expect(mockExecuteProviderRequest.mock.calls[0][1].bedrockSession).toEqual(session) + }) + it('preserves route selection and reasoning when an Auto request falls back', async () => { mockExecuteProviderRequest .mockRejectedValueOnce(new Error('overloaded')) diff --git a/apps/sim/executor/handlers/router/handler.ts b/apps/sim/executor/handlers/router/handler.ts index 8abcf52792b..784bc35620d 100644 --- a/apps/sim/executor/handlers/router/handler.ts +++ b/apps/sim/executor/handlers/router/handler.ts @@ -20,7 +20,6 @@ import type { BlockHandler, BlockNodeMetadata, ExecutionContext } from '@/execut import { executeModelRequestWithFallbacks } from '@/executor/utils/model-fallback-request' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' -import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { resolveProxiedModelCost } from '@/providers/cost-policy' import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' import type { ProviderRequest } from '@/providers/types' @@ -96,6 +95,8 @@ export class RouterBlockHandler implements BlockHandler { bedrockAccessKeyId: inputs.bedrockAccessKeyId, bedrockSecretKey: inputs.bedrockSecretKey, bedrockRegion: inputs.bedrockRegion, + bedrockCredential: inputs.bedrockCredential, + bedrockAuthMode: inputs.bedrockAuthMode, } try { @@ -113,24 +114,12 @@ export class RouterBlockHandler implements BlockHandler { await validateModelProvider(ctx.userId, ctx.workspaceId, resolved.model, ctx) const providerId = getProviderFromModel(resolved.model) - let finalApiKey: string | undefined = routerConfig.apiKey - if (providerId === 'vertex' && routerConfig.vertexCredential) { - finalApiKey = await resolveVertexCredential({ - credentialId: routerConfig.vertexCredential, - actingUserId: ctx.userId, - workspaceId: ctx.workspaceId, - workflowId: ctx.workflowId, - executionId: ctx.executionId, - callerLabel: 'vertex-router', - }) - } - const providerRequest: ProviderRequest = { model: resolved.model, systemPrompt: resolved.systemPrompt, context: JSON.stringify(messages), temperature: ROUTER.INFERENCE_TEMPERATURE, - apiKey: finalApiKey, + apiKey: routerConfig.apiKey, azureEndpoint: inputs.azureEndpoint, azureApiVersion: inputs.azureApiVersion, vertexProject: routerConfig.vertexProject, @@ -152,6 +141,7 @@ export class RouterBlockHandler implements BlockHandler { providerId, request: providerRequest, resolvedSecretTraceRegistry: modelInputProjection.registry, + credentials: { inputs: routerConfig, callerLabel: 'vertex-router' }, }) const chosenBlockId = result.content.trim().toLowerCase() @@ -266,6 +256,8 @@ export class RouterBlockHandler implements BlockHandler { bedrockAccessKeyId: inputs.bedrockAccessKeyId, bedrockSecretKey: inputs.bedrockSecretKey, bedrockRegion: inputs.bedrockRegion, + bedrockCredential: inputs.bedrockCredential, + bedrockAuthMode: inputs.bedrockAuthMode, } try { @@ -283,24 +275,12 @@ export class RouterBlockHandler implements BlockHandler { await validateModelProvider(ctx.userId, ctx.workspaceId, resolved.model, ctx) const providerId = getProviderFromModel(resolved.model) - let finalApiKey: string | undefined = routerConfig.apiKey - if (providerId === 'vertex' && routerConfig.vertexCredential) { - finalApiKey = await resolveVertexCredential({ - credentialId: routerConfig.vertexCredential, - actingUserId: ctx.userId, - workspaceId: ctx.workspaceId, - workflowId: ctx.workflowId, - executionId: ctx.executionId, - callerLabel: 'vertex-router', - }) - } - const providerRequest: ProviderRequest = { model: resolved.model, systemPrompt: resolved.systemPrompt, context: JSON.stringify(messages), temperature: ROUTER.INFERENCE_TEMPERATURE, - apiKey: finalApiKey, + apiKey: routerConfig.apiKey, azureEndpoint: inputs.azureEndpoint, azureApiVersion: inputs.azureApiVersion, vertexProject: routerConfig.vertexProject, @@ -341,6 +321,7 @@ export class RouterBlockHandler implements BlockHandler { providerId, request: providerRequest, resolvedSecretTraceRegistry: modelInputProjection.registry, + credentials: { inputs: routerConfig, callerLabel: 'vertex-router' }, }) let chosenRouteId: string diff --git a/apps/sim/executor/utils/model-credentials.ts b/apps/sim/executor/utils/model-credentials.ts new file mode 100644 index 00000000000..943ca4bd258 --- /dev/null +++ b/apps/sim/executor/utils/model-credentials.ts @@ -0,0 +1,64 @@ +import { resolveBedrockCredentialSession } from '@/lib/internal/llm/bedrock-credential' +import { getSelectedBedrockCredential } from '@/blocks/utils' +import type { ExecutionContext } from '@/executor/types' +import { resolveVertexCredential } from '@/executor/utils/vertex-credential' +import type { BedrockCredentialSession } from '@/providers/types' + +/** + * The block fields that select a saved model credential. A fallback on another + * provider drops them, so one provider's credential never reaches another. + */ +export const BLOCK_MODEL_CREDENTIAL_FIELDS = [ + 'vertexCredential', + 'bedrockCredential', + 'bedrockAuthMode', +] as const + +/** A block's saved model credential selection. */ +export type BlockModelCredentialInputs = Parameters[0] & { + vertexCredential?: string +} + +/** What a saved credential adds to a provider request; a field is present only when it resolved. */ +export interface BlockModelCredentials { + apiKey?: string + bedrockSession?: BedrockCredentialSession +} + +/** + * Resolves the saved credential a block's model call signs with: a Vertex AI + * OAuth or service-account token (returned as `apiKey`), or a Bedrock AWS + * credential session. Resolution runs for the run's acting user within the + * workflow's workspace, and a failure fails that attempt rather than letting it + * fall back to other credentials. + */ +export async function resolveBlockModelCredentials( + ctx: ExecutionContext, + providerId: string, + inputs: BlockModelCredentialInputs, + callerLabel: string +): Promise { + if (providerId === 'vertex' && inputs.vertexCredential) { + const apiKey = await resolveVertexCredential({ + credentialId: inputs.vertexCredential, + actingUserId: ctx.userId, + workspaceId: ctx.workspaceId, + workflowId: ctx.workflowId, + executionId: ctx.executionId, + callerLabel, + }) + return { apiKey } + } + if (providerId !== 'bedrock') return {} + const credentialId = getSelectedBedrockCredential(inputs) + if (!credentialId) return {} + const bedrockSession = await resolveBedrockCredentialSession({ + credentialId, + actingUserId: ctx.userId, + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + executionId: ctx.executionId, + signal: ctx.abortSignal, + }) + return { bedrockSession } +} diff --git a/apps/sim/executor/utils/model-fallback-request.test.ts b/apps/sim/executor/utils/model-fallback-request.test.ts index ab25ce03736..045c046db46 100644 --- a/apps/sim/executor/utils/model-fallback-request.test.ts +++ b/apps/sim/executor/utils/model-fallback-request.test.ts @@ -1,4 +1,8 @@ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { + llmBedrockCredentialMock, + llmBedrockCredentialMockFns, +} from '@sim/testing/mocks/llm-bedrock-credential.mock' import { permissionCheckMock, permissionCheckMockFns, @@ -11,8 +15,10 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr import type { SerializedBlock } from '@/serializer/types' const validateModel = permissionCheckMockFns.mockValidateModelProvider +const resolveBedrockSession = llmBedrockCredentialMockFns.mockResolveBedrockCredentialSession providersUtilsMockFns.mockGetProviderFromModel.mockImplementation((model: string) => { if (model.startsWith('claude')) return 'anthropic' + if (model.startsWith('bedrock/')) return 'bedrock' if (model.startsWith('vertex/')) return 'vertex' return 'openai' }) @@ -24,6 +30,7 @@ const { request } = vi.hoisted(() => ({ vi.mock('@/executor/utils/provider-request', () => ({ executeBlockProviderRequest: request })) vi.mock('@/ee/access-control/utils/permission-check', () => permissionCheckMock) vi.mock('@/providers/utils', () => providersUtilsMock) +vi.mock('@/lib/internal/llm/bedrock-credential', () => llmBedrockCredentialMock) function context(): ExecutionContext { return { @@ -246,6 +253,62 @@ describe('executeModelRequestWithFallbacks', () => { expect(request.mock.calls[1][0].request.apiKey).toBeUndefined() }) + it('reuses the saved AWS session for a same-provider fallback after the primary model fails', async () => { + const session = { + credentialId: 'aws-cred', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + } + resolveBedrockSession.mockResolvedValueOnce(session) + request.mockRejectedValueOnce(new Error('overloaded')) + + const output = await executeModelRequestWithFallbacks({ + ...input(), + providerId: 'bedrock', + configuredModel: 'bedrock/claude-sonnet', + request: { model: 'bedrock/claude-sonnet', systemPrompt: 'Score the content' }, + fallbackModels: [{ model: 'bedrock/claude-haiku' }], + credentials: { + inputs: { bedrockAuthMode: 'credential', bedrockCredential: 'aws-cred' }, + callerLabel: 'test', + }, + }) + + expect(output.result.model).toBe('bedrock/claude-haiku') + expect(resolveBedrockSession).toHaveBeenCalledTimes(1) + expect(request.mock.calls.map(([args]) => args.request.bedrockSession)).toEqual([ + session, + session, + ]) + }) + + it('fails every same-provider candidate after one failed credential resolution, then falls back without the session', async () => { + resolveBedrockSession.mockRejectedValueOnce(new Error('AWS STS is unavailable')) + const options = input() + + const output = await executeModelRequestWithFallbacks({ + ...options, + providerId: 'bedrock', + configuredModel: 'bedrock/claude-sonnet', + request: { model: 'bedrock/claude-sonnet', systemPrompt: 'Score the content' }, + fallbackModels: [{ model: 'bedrock/claude-haiku' }, { model: 'claude-sonnet-5' }], + credentials: { + inputs: { bedrockAuthMode: 'credential', bedrockCredential: 'aws-cred' }, + callerLabel: 'test', + }, + }) + + expect(output.result.model).toBe('claude-sonnet-5') + expect(resolveBedrockSession).toHaveBeenCalledTimes(1) + expect(request.mock.calls.map(([args]) => args.providerId)).toEqual(['anthropic']) + expect(request.mock.calls[0][0].request.bedrockSession).toBeUndefined() + expect(options.ctx.blockLogs[0].modelFallbacks).toEqual([ + 'bedrock/claude-sonnet', + 'bedrock/claude-haiku', + ]) + }) + it('strips the Auto identity prompt from fallbacks and keeps the pool model out of the trace', async () => { const options = input() request.mockRejectedValueOnce(new Error('overloaded')) diff --git a/apps/sim/executor/utils/model-fallback-request.ts b/apps/sim/executor/utils/model-fallback-request.ts index 20f9183c9d7..e4687e58c90 100644 --- a/apps/sim/executor/utils/model-fallback-request.ts +++ b/apps/sim/executor/utils/model-fallback-request.ts @@ -4,6 +4,11 @@ import { resolveFallbackTuning } from '@/lib/workflows/blocks/fallback-models' import { validateModelProvider } from '@/ee/access-control/utils/permission-check' import { isRetryableBlockError } from '@/executor/execution/block-retry' import type { BlockRetryAttempt, ExecutionContext } from '@/executor/types' +import { + type BlockModelCredentialInputs, + type BlockModelCredentials, + resolveBlockModelCredentials, +} from '@/executor/utils/model-credentials' import { assertFallbackFamilyCredentials, getModelFallbacks, @@ -32,11 +37,16 @@ interface ModelFallbackRequestInput { fallbackSystemPrompt: string retry?: BlockRetryAttempt resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined + /** The block's saved Vertex or Bedrock credential selection, resolved per attempt. */ + credentials?: { inputs: BlockModelCredentialInputs; callerLabel: string } } /** * Shared non-streaming model execution for Router and Evaluator. Block retries * exhaust the primary first; the final try walks the ordered fallback models. + * Saved credentials resolve inside the attempt, so one that cannot be resolved fails + * that attempt and the fallbacks still run. Only candidates on the primary's provider + * resolve them, once per call, and they never reach another provider. * Parsing a successful response remains the handler's job, so a routing decision * such as NO_MATCH is never silently replaced by another model's decision. */ @@ -50,6 +60,7 @@ export async function executeModelRequestWithFallbacks({ fallbackSystemPrompt, retry, resolvedSecretTraceRegistry, + credentials, }: ModelFallbackRequestInput): Promise<{ result: ProviderResponse; usedFallback: boolean }> { const fallbacks = getModelFallbacks(ctx, block, fallbackModels, logger) const candidates = [ @@ -62,6 +73,7 @@ export async function executeModelRequestWithFallbacks({ ] const failedModels: string[] = [] let lastError: unknown + let savedCredentials: Promise | undefined for (const [index, candidate] of candidates.entries()) { const isPrimary = index === 0 @@ -93,7 +105,9 @@ export async function executeModelRequestWithFallbacks({ request ) candidateRequest = { - ...(sameProvider ? request : omit(request, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS])), + ...(sameProvider + ? request + : omit(request, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS, 'bedrockSession'])), ...tuning, temperature: tuning.temperature === undefined ? undefined : Number(tuning.temperature), maxTokens: tuning.maxTokens === undefined ? undefined : Number(tuning.maxTokens), @@ -111,6 +125,15 @@ export async function executeModelRequestWithFallbacks({ } try { + if (credentials && candidateProviderId === providerId) { + savedCredentials ??= resolveBlockModelCredentials( + ctx, + providerId, + credentials.inputs, + credentials.callerLabel + ) + candidateRequest = { ...candidateRequest, ...(await savedCredentials) } + } const result = await executeBlockProviderRequest({ ctx, providerId: candidateProviderId, diff --git a/apps/sim/hooks/use-computer-use-status.ts b/apps/sim/hooks/use-computer-use-status.ts index a44fd9e69fb..7521354f4f4 100644 --- a/apps/sim/hooks/use-computer-use-status.ts +++ b/apps/sim/hooks/use-computer-use-status.ts @@ -1,11 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { ComputerUseStatus } from '@sim/desktop-bridge' import { getDesktopBridge } from '@/lib/desktop' -import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' /** Refresh native permissions when the user returns from System Settings and on activity changes. */ export function useComputerUseStatus() { - const enabled = useFeatureFlag('mothership-computer-use') const requestVersion = useRef(0) const activityVersion = useRef(0) const [status, setStatus] = useState | null>(null) @@ -21,7 +19,7 @@ export function useComputerUseStatus() { }, []) const refresh = useCallback(async () => { const bridge = getDesktopBridge()?.computerUse - if (!bridge || !enabled) return + if (!bridge) return const version = ++requestVersion.current try { const nextStatus = await bridge.getStatus() @@ -31,7 +29,7 @@ export function useComputerUseStatus() { if (version !== requestVersion.current) return setError(true) } - }, [enabled, updateStatus]) + }, [updateStatus]) useEffect(() => { const bridge = getDesktopBridge()?.computerUse if (!bridge) return @@ -51,19 +49,19 @@ export function useComputerUseStatus() { const onFocus = () => { void refresh() } - if (enabled) window.addEventListener('focus', onFocus) + window.addEventListener('focus', onFocus) return () => { requestVersion.current += 1 activityVersion.current += 1 unsubscribe() window.removeEventListener('focus', onFocus) } - }, [enabled, refresh]) + }, [refresh]) return { - status: enabled && status ? { ...status, activeAction } : null, + status: status ? { ...status, activeAction } : null, activeAction, setStatus: updateStatus, refresh, - error: enabled && error, + error, } } diff --git a/apps/sim/lib/api/contracts/computer-use.ts b/apps/sim/lib/api/contracts/computer-use.ts index 3c4737e6493..52f2730a46b 100644 --- a/apps/sim/lib/api/contracts/computer-use.ts +++ b/apps/sim/lib/api/contracts/computer-use.ts @@ -1,22 +1,7 @@ import { ComputerUseSchema } from '@sim/desktop-bridge/computer-use' import { z } from 'zod' import { desktopToolCallIdSchema } from '@/lib/api/contracts/desktop-tool-authorization' -import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' -import { type ContractQuery, defineRouteContract } from '@/lib/api/contracts/types' - -/** The chat owner whose rollout applies: exactly one of a workspace or an organization. */ -const computerUseAvailabilityQuerySchema = z.union([ - z.object({ workspaceId: workspaceIdSchema }).strict(), - z.object({ organizationId: organizationIdSchema }).strict(), -]) -const computerUseAvailabilityResponseSchema = z.object({ enabled: z.boolean() }).strict() -export const computerUseAvailabilityContract = defineRouteContract({ - method: 'GET', - path: '/api/desktop/computer/availability', - query: computerUseAvailabilityQuerySchema, - response: { mode: 'json', schema: computerUseAvailabilityResponseSchema }, -}) -export type ComputerUseOwner = ContractQuery +import { defineRouteContract } from '@/lib/api/contracts/types' const authorizeComputerUseBodySchema = z.object({ toolCallId: desktopToolCallIdSchema }).strict() const authorizeComputerUseResponseSchema = z diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index ca17bf95a48..eeb55e89558 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { MICROSOFT_CLOUD_IDS } from '@/lib/credentials/microsoft-clouds' import { ATLASSIAN_PRODUCTS, getMissingServiceAccountFields, @@ -61,6 +62,8 @@ export const workspaceCredentialSchema = z.object({ envKey: z.string().nullable(), envOwnerUserId: z.string().nullable(), instanceUrl: z.string().url().optional(), + /** ISO time the saved token stops working, when its vendor stated one; absent when it never expires or is unknown. */ + expiresAt: z.string().optional(), createdBy: z.string(), createdAt: z.string(), updatedAt: z.string(), @@ -574,6 +577,18 @@ export const createWorkspaceCredentialContract = defineRouteContract({ */ /** The reconnected credential's stored auth mode, so the dialog reopens on it. */ const reconnectAuthTypeSchema = z.enum(WORKLOAD_IDENTITY_AUTH_TYPE_LIST).optional() +/** A reconnected Microsoft app credential's national cloud, absent for the global cloud. */ +const reconnectCloudSchema = z.enum(MICROSOFT_CLOUD_IDS).optional() + +/** A reconnected keyless credential's custom audience; absent when it uses the default. */ +const reconnectAudienceSchema = z.string().optional() + +/** A reconnected keyless Google credential's domain-wide delegation, so the dialog keeps it. */ +const reconnectDelegationSchema = z + .object({ user: z.string(), services: z.array(z.string()) }) + .optional() + +export type ReconnectDelegation = NonNullable> export const workloadIdentitySubjectGrantShape = { issuer: z @@ -583,7 +598,7 @@ export const workloadIdentitySubjectGrantShape = { .string() .optional() .describe( - "The `aud` claim the cloud must accept. Absent for Google Cloud, whose audience is each credential's own workload identity pool provider." + "The default `aud` claim the cloud must accept; a keyless config's optional `audience` replaces it. Absent for Google Cloud, whose default audience is each credential's own workload identity pool provider." ), subject: z .string() @@ -613,11 +628,20 @@ export const prepareWorkloadIdentityContract = defineRouteContract({ mode: 'json', schema: z.discriminatedUnion('available', [ /** This deployment has no workload-identity signing key; offer key-based modes only. */ - z.object({ available: z.literal(false), reconnectAuthType: reconnectAuthTypeSchema }), + z.object({ + available: z.literal(false), + reconnectAuthType: reconnectAuthTypeSchema, + reconnectDelegation: reconnectDelegationSchema, + reconnectCloud: reconnectCloudSchema, + reconnectAudience: reconnectAudienceSchema, + }), z.object({ available: z.literal(true), ...workloadIdentitySubjectGrantShape, reconnectAuthType: reconnectAuthTypeSchema, + reconnectDelegation: reconnectDelegationSchema, + reconnectCloud: reconnectCloudSchema, + reconnectAudience: reconnectAudienceSchema, }), ]), }, diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index e166d60ec27..b0555c1ec78 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -125,7 +125,9 @@ const oauthTokenResponseSchema = z.object({ applicationKey: z.string().optional(), realmId: z.string().optional(), quickBooksEnvironment: z.enum(['sandbox', 'production']).optional(), - authStyle: z.enum(['x-api-token', 'snowflake-workload-identity', 'basic']).optional(), + authStyle: z + .enum(['x-api-token', 'snowflake-workload-identity', 'snowflake-key-pair', 'basic', 'bearer']) + .optional(), }) /** Token material a resolved credential yields, on the wire and in-process alike. */ diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts b/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts index 2d0d0de38dd..f4fc6148880 100644 --- a/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts +++ b/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts @@ -1,5 +1,8 @@ import { z } from 'zod' -import { athenaConnectionSchema } from '@/lib/api/contracts/tools/aws/athena-shared' +import { + athenaConnectionSchema, + athenaQueryExecutionDetailsShape, +} from '@/lib/api/contracts/tools/aws/athena-shared' import type { ContractBody, ContractBodyInput, @@ -35,6 +38,7 @@ const BatchGetQueryExecutionResponseSchema = z.object({ queryQueueTimeInMillis: z.number().nullable(), totalExecutionTimeInMillis: z.number().nullable(), outputLocation: z.string().nullable(), + ...athenaQueryExecutionDetailsShape, }) ), unprocessedQueryExecutionIds: z.array( diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts index 7383ab81d99..7847b402b6a 100644 --- a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts +++ b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts @@ -1,5 +1,8 @@ import { z } from 'zod' -import { athenaConnectionSchema } from '@/lib/api/contracts/tools/aws/athena-shared' +import { + athenaConnectionSchema, + athenaQueryExecutionDetailsShape, +} from '@/lib/api/contracts/tools/aws/athena-shared' import type { ContractBody, ContractBodyInput, @@ -30,6 +33,7 @@ const GetQueryExecutionResponseSchema = z.object({ queryQueueTimeInMillis: z.number().nullable(), totalExecutionTimeInMillis: z.number().nullable(), outputLocation: z.string().nullable(), + ...athenaQueryExecutionDetailsShape, }), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts index 5fa8d7f362b..50eee5c2b84 100644 --- a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts +++ b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts @@ -23,6 +23,11 @@ const GetQueryResultsResponseSchema = z.object({ z.object({ name: z.string(), type: z.string(), + label: z.string().nullable(), + precision: z.number().nullable(), + scale: z.number().nullable(), + nullable: z.string().nullable(), + caseSensitive: z.boolean().nullable(), }) ), rows: z.array(z.record(z.string(), z.string())), diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-shared.ts b/apps/sim/lib/api/contracts/tools/aws/athena-shared.ts index 5bddcc9266b..3db4a7836db 100644 --- a/apps/sim/lib/api/contracts/tools/aws/athena-shared.ts +++ b/apps/sim/lib/api/contracts/tools/aws/athena-shared.ts @@ -105,6 +105,25 @@ export const athenaEngineVersionSchema = z.object({ effectiveEngineVersion: z.string().nullable(), }) +/** Query execution fields beyond the core status and statistics. */ +export const athenaQueryExecutionDetailsShape = { + servicePreProcessingTimeInMillis: z.number().nullable(), + serviceProcessingTimeInMillis: z.number().nullable(), + dataManifestLocation: z.string().nullable(), + reusedPreviousResult: z.boolean().nullable(), + athenaError: z + .object({ + errorCategory: z.number().nullable(), + errorType: z.number().nullable(), + retryable: z.boolean().nullable(), + errorMessage: z.string().nullable(), + }) + .nullable(), + executionParameters: z.array(z.string()).nullable(), + substatementType: z.string().nullable(), + engineVersion: athenaEngineVersionSchema.nullable(), +} + export const athenaSuccessResponseSchema = z.object({ success: z.literal(true), output: z.object({ success: z.literal(true) }), diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-start-query.ts b/apps/sim/lib/api/contracts/tools/aws/athena-start-query.ts index 3d3d38f69c4..6cdaf7e8169 100644 --- a/apps/sim/lib/api/contracts/tools/aws/athena-start-query.ts +++ b/apps/sim/lib/api/contracts/tools/aws/athena-start-query.ts @@ -25,6 +25,10 @@ const StartQuerySchema = athenaConnectionSchema.extend({ (v) => (v === '' || v === undefined || v === null ? undefined : v), z.coerce.number().int().min(0).max(10080).optional() ), + encryptionOption: z.enum(['SSE_S3', 'SSE_KMS', 'CSE_KMS']).optional(), + kmsKey: z.string().optional(), + expectedBucketOwner: z.string().optional(), + clientRequestToken: z.string().min(32).max(128).optional(), }) const StartQueryResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts index 35ab5c3f3b2..fb3a1afc9ef 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts @@ -50,6 +50,18 @@ const CreateChangeSetSchema = z ), changeSetType: z.enum(['CREATE', 'UPDATE', 'IMPORT']).optional(), description: z.string().max(1024, 'Description must be at most 1024 characters').optional(), + roleArn: z.string().min(1).optional(), + tags: z + .array( + z.object({ + key: z + .string() + .min(1, 'Tag key is required') + .max(128, 'Tag key must be at most 128 characters'), + value: z.string().max(256, 'Tag value must be at most 256 characters'), + }) + ) + .optional(), }) .superRefine((data, ctx) => { if (!data.templateBody && !data.usePreviousTemplate) { diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts index 8b56f1ee24f..abe49a442c1 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts @@ -58,6 +58,8 @@ const CreateStackSchema = z.object({ .optional(), onFailure: z.enum(['ROLLBACK', 'DELETE', 'DO_NOTHING']).optional(), timeoutInMinutes: z.coerce.number().int().positive().optional(), + roleArn: z.string().min(1).optional(), + enableTerminationProtection: z.boolean().optional(), }) const CreateStackResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts index d3353c05d4a..ba9e123da83 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts @@ -18,6 +18,7 @@ const DescribeChangeSetSchema = z.object({ secretAccessKey: z.string().min(1, 'AWS secret access key is required'), changeSetName: z.string().min(1, 'Change set name is required'), stackName: z.string().optional(), + nextToken: z.string().optional(), }) const DescribeChangeSetResponseSchema = z.object({ @@ -40,8 +41,38 @@ const DescribeChangeSetResponseSchema = z.object({ physicalResourceId: z.string().optional(), resourceType: z.string().optional(), replacement: z.string().optional(), + type: z.string().optional(), + policyAction: z.string().optional(), + scope: z.array(z.string()), + changeSetId: z.string().optional(), + details: z.array( + z.object({ + attribute: z.string().optional(), + name: z.string().optional(), + requiresRecreation: z.string().optional(), + evaluation: z.string().optional(), + changeSource: z.string().optional(), + causingEntity: z.string().optional(), + }) + ), }) ), + parameters: z.array( + z.object({ + parameterKey: z.string(), + parameterValue: z.string().optional(), + usePreviousValue: z.boolean().optional(), + resolvedValue: z.string().optional(), + }) + ), + tags: z.array(z.object({ key: z.string(), value: z.string() })), + notificationArns: z.array(z.string()), + includeNestedStacks: z.boolean().optional(), + parentChangeSetId: z.string().optional(), + rootChangeSetId: z.string().optional(), + onStackFailure: z.string().optional(), + importExistingResources: z.boolean().optional(), + nextToken: z.string().nullable(), }), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stack-events.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stack-events.ts index a052375a958..17cc8f455c6 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stack-events.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stack-events.ts @@ -31,6 +31,15 @@ const DescribeStackEventsResponseSchema = z.object({ resourceStatus: z.string().optional(), resourceStatusReason: z.string().optional(), timestamp: z.number().optional(), + operationId: z.string().optional(), + resourceProperties: z.string().optional(), + clientRequestToken: z.string().optional(), + detailedStatus: z.string().optional(), + hookType: z.string().optional(), + hookStatus: z.string().optional(), + hookStatusReason: z.string().optional(), + hookInvocationPoint: z.string().optional(), + hookFailureMode: z.string().optional(), }) ), }), diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stacks.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stacks.ts index 87ecea916f3..eae930abd46 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stacks.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stacks.ts @@ -37,6 +37,7 @@ const DescribeStacksResponseSchema = z.object({ outputKey: z.string(), outputValue: z.string(), description: z.string().optional(), + exportName: z.string().optional(), }) ), tags: z.array( @@ -45,6 +46,26 @@ const DescribeStacksResponseSchema = z.object({ value: z.string(), }) ), + parameters: z.array( + z.object({ + parameterKey: z.string(), + parameterValue: z.string().optional(), + usePreviousValue: z.boolean().optional(), + resolvedValue: z.string().optional(), + }) + ), + capabilities: z.array(z.string()), + changeSetId: z.string().optional(), + deletionTime: z.number().optional(), + disableRollback: z.boolean().optional(), + notificationArns: z.array(z.string()), + timeoutInMinutes: z.number().optional(), + roleArn: z.string().optional(), + parentId: z.string().optional(), + rootId: z.string().optional(), + detailedStatus: z.string().optional(), + deletionMode: z.string().optional(), + retainExceptOnCreate: z.boolean().optional(), }) ), }), diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts index ccf0cf7c067..f2a3c60e544 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts @@ -41,6 +41,7 @@ const GetTemplateSummaryResponseSchema = z.object({ parameterType: z.string().optional(), noEcho: z.boolean().optional(), description: z.string().optional(), + allowedValues: z.array(z.string()), }) ), capabilities: z.array(z.string()), diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts index 9c9e8506570..a735940d32c 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts @@ -58,6 +58,7 @@ const UpdateStackSchema = z }) ) .optional(), + roleArn: z.string().min(1).optional(), }) .superRefine((data, ctx) => { if (!data.templateBody && !data.usePreviousTemplate) { diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts index 22e241e1ccc..86a51cba138 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts @@ -51,6 +51,8 @@ const DescribeAlarmHistoryResponseSchema = z.object({ timestamp: z.number().optional(), historyItemType: z.string().optional(), historySummary: z.string().optional(), + historyData: z.string().optional(), + alarmContributorId: z.string().optional(), }) ), }), diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts index 45322bcf4cf..6ccf96aef7d 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts @@ -34,6 +34,7 @@ const DescribeAlarmsSchema = z.object({ .max(100, 'limit must be at most 100 (CloudWatch DescribeAlarms limit)') .optional() ), + nextToken: z.string().optional(), }) const DescribeAlarmsResponseSchema = z.object({ @@ -51,8 +52,49 @@ const DescribeAlarmsResponseSchema = z.object({ threshold: z.number().optional(), evaluationPeriods: z.number().optional(), stateUpdatedTimestamp: z.number().optional(), + alarmType: z.string(), + alarmDescription: z.string().optional(), + actionsEnabled: z.boolean().optional(), + alarmActions: z.array(z.string()), + okActions: z.array(z.string()), + insufficientDataActions: z.array(z.string()), + stateReasonData: z.string().optional(), + stateTransitionedTimestamp: z.number().optional(), + alarmConfigurationUpdatedTimestamp: z.number().optional(), + statistic: z.string().optional(), + extendedStatistic: z.string().optional(), + dimensions: z.array(z.object({ name: z.string(), value: z.string() })).optional(), + period: z.number().optional(), + unit: z.string().optional(), + datapointsToAlarm: z.number().optional(), + treatMissingData: z.string().optional(), + thresholdMetricId: z.string().optional(), + metrics: z + .array( + z.object({ + id: z.string(), + expression: z.string().optional(), + label: z.string().optional(), + returnData: z.boolean().optional(), + period: z.number().optional(), + accountId: z.string().optional(), + metricStat: z + .object({ + namespace: z.string().optional(), + metricName: z.string().optional(), + dimensions: z.array(z.object({ name: z.string(), value: z.string() })), + period: z.number().optional(), + stat: z.string().optional(), + unit: z.string().optional(), + }) + .optional(), + }) + ) + .optional(), + alarmRule: z.string().optional(), }) ), + nextToken: z.string().nullable(), }), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts index 3233f6202b6..79d0fbadf69 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts @@ -26,6 +26,7 @@ const FilterLogEventsSchema = z.object({ (v) => (v === '' || v === undefined || v === null ? undefined : v), z.coerce.number().int().positive().optional() ), + nextToken: z.string().optional(), }) const FilterLogEventsResponseSchema = z.object({ @@ -37,8 +38,10 @@ const FilterLogEventsResponseSchema = z.object({ timestamp: z.number().optional(), message: z.string().optional(), ingestionTime: z.number().optional(), + eventId: z.string().optional(), }) ), + nextToken: z.string().nullable(), }), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts index 3bfff17b8c3..00a8ecb1bbd 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts @@ -29,6 +29,7 @@ const GetLogEventsSchema = z.object({ .max(10000, 'limit must be at most 10000 (CloudWatch Logs GetLogEvents limit)') .optional() ), + nextToken: z.string().optional(), }) const GetLogEventsResponseSchema = z.object({ @@ -41,6 +42,8 @@ const GetLogEventsResponseSchema = z.object({ ingestionTime: z.number().optional(), }) ), + nextForwardToken: z.string().nullable(), + nextBackwardToken: z.string().nullable(), }), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts index 80278dca99c..f36bc514780 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts @@ -7,26 +7,33 @@ import type { import { defineRouteContract } from '@/lib/api/contracts/types' import { validateAwsRegion } from '@/lib/core/security/input-validation' -const GetMetricStatisticsSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - namespace: z.string().min(1, 'Namespace is required'), - metricName: z.string().min(1, 'Metric name is required'), - startTime: z.coerce.number().int(), - endTime: z.coerce.number().int(), - period: z.coerce.number().int().min(1), - statistics: z - .array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])) - .min(1, 'At least one statistic is required') - .max(5, 'At most 5 statistics are allowed per GetMetricStatistics call'), - dimensions: z.string().optional(), -}) +const GetMetricStatisticsSchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + namespace: z.string().min(1, 'Namespace is required'), + metricName: z.string().min(1, 'Metric name is required'), + startTime: z.coerce.number().int(), + endTime: z.coerce.number().int(), + period: z.coerce.number().int().min(1), + statistics: z + .array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])) + .max(5, 'At most 5 statistics are allowed per GetMetricStatistics call') + .optional(), + dimensions: z.string().optional(), + extendedStatistics: z.array(z.string().min(1)).max(10).optional(), + unit: z.string().optional(), + }) + .refine((body) => Boolean(body.statistics?.length || body.extendedStatistics?.length), { + message: 'Provide statistics or extendedStatistics', + path: ['statistics'], + }) const GetMetricStatisticsResponseSchema = z.object({ success: z.literal(true), @@ -41,6 +48,7 @@ const GetMetricStatisticsResponseSchema = z.object({ maximum: z.number().optional(), sampleCount: z.number().optional(), unit: z.string().optional(), + extendedStatistics: z.record(z.string(), z.number()).optional(), }) ), }), diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-query-logs.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-query-logs.ts index 4de4c84d4a2..58ffa561022 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-query-logs.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-query-logs.ts @@ -36,6 +36,7 @@ const QueryLogsResponseSchema = z.object({ recordsScanned: z.number(), }), status: z.string(), + queryId: z.string(), }), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-delete.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-delete.ts index 2333b0fcf92..b0c57015cd7 100644 --- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-delete.ts +++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-delete.ts @@ -23,10 +23,13 @@ const DeleteSchema = z.object({ conditionExpression: z.string().optional(), expressionAttributeNames: z.record(z.string(), z.string()).optional(), expressionAttributeValues: z.record(z.string(), z.unknown()).optional(), + returnValues: z.enum(['NONE', 'ALL_OLD']).optional(), }) const DeleteResponseSchema = z.object({ message: z.string(), + // untyped-response: DynamoDB DeleteItem ALL_OLD Attributes is an arbitrary user attribute-value record + attributes: z.record(z.string(), z.unknown()).nullable().optional(), }) export const awsDynamodbDeleteContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-get.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-get.ts index d487ce13050..b93146169fe 100644 --- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-get.ts +++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-get.ts @@ -27,6 +27,8 @@ const GetSchema = z.object({ if (val === true || val === 'true') return true return undefined }), + projectionExpression: z.string().optional(), + expressionAttributeNames: z.record(z.string(), z.string()).optional(), }) const GetResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-introspect.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-introspect.ts index 2c0d1afdcd8..60532b3fe77 100644 --- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-introspect.ts +++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-introspect.ts @@ -46,6 +46,12 @@ const TableDetailsSchema = z.object({ itemCount: z.number(), tableSizeBytes: z.number(), billingMode: z.string(), + tableArn: z.string().nullable(), + creationDateTime: z.string().nullable(), + deletionProtectionEnabled: z.boolean().nullable(), + streamEnabled: z.boolean().nullable(), + streamViewType: z.string().nullable(), + latestStreamArn: z.string().nullable(), }) const IntrospectResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-put.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-put.ts index 5fb4a57bb0e..bf93f0497c8 100644 --- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-put.ts +++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-put.ts @@ -23,12 +23,15 @@ const PutSchema = z.object({ conditionExpression: z.string().optional(), expressionAttributeNames: z.record(z.string(), z.string()).optional(), expressionAttributeValues: z.record(z.string(), z.unknown()).optional(), + returnValues: z.enum(['NONE', 'ALL_OLD']).optional(), }) const PutResponseSchema = z.object({ message: z.string(), // untyped-response: DynamoDB Item is an arbitrary user attribute-value record item: z.record(z.string(), z.unknown()), + // untyped-response: DynamoDB PutItem ALL_OLD Attributes is an arbitrary user attribute-value record + attributes: z.record(z.string(), z.unknown()).nullable().optional(), }) export const awsDynamodbPutContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-query.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-query.ts index e68f6298d4c..2b5f4515503 100644 --- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-query.ts +++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-query.ts @@ -24,7 +24,22 @@ const QuerySchema = z.object({ indexName: z.string().optional(), limit: z.number().positive().optional(), exclusiveStartKey: z.record(z.string(), z.unknown()).optional(), - scanIndexForward: z.boolean().optional(), + scanIndexForward: z + .union([z.boolean(), z.enum(['true', 'false'])]) + .optional() + .transform((val) => { + if (val === false || val === 'false') return false + if (val === true || val === 'true') return true + return undefined + }), + projectionExpression: z.string().optional(), + consistentRead: z + .union([z.boolean(), z.enum(['true', 'false'])]) + .optional() + .transform((val) => { + if (val === true || val === 'true') return true + return undefined + }), }) const QueryResponseSchema = z.object({ @@ -32,6 +47,7 @@ const QueryResponseSchema = z.object({ // untyped-response: DynamoDB Items are arbitrary user attribute-value records items: z.array(z.record(z.string(), z.unknown())), count: z.number(), + scannedCount: z.number().nullable(), // untyped-response: DynamoDB LastEvaluatedKey mirrors the table's primary key shape lastEvaluatedKey: z.record(z.string(), z.unknown()).optional(), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-scan.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-scan.ts index 10bc7231599..ee57f699c5d 100644 --- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-scan.ts +++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-scan.ts @@ -7,29 +7,60 @@ import type { import { defineRouteContract } from '@/lib/api/contracts/types' import { validateAwsRegion } from '@/lib/core/security/input-validation' -const ScanSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - tableName: z.string().min(1, 'Table name is required'), - filterExpression: z.string().optional(), - projectionExpression: z.string().optional(), - expressionAttributeNames: z.record(z.string(), z.string()).optional(), - expressionAttributeValues: z.record(z.string(), z.unknown()).optional(), - limit: z.number().positive().optional(), - exclusiveStartKey: z.record(z.string(), z.unknown()).optional(), -}) +const ScanSchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + tableName: z.string().min(1, 'Table name is required'), + filterExpression: z.string().optional(), + projectionExpression: z.string().optional(), + expressionAttributeNames: z.record(z.string(), z.string()).optional(), + expressionAttributeValues: z.record(z.string(), z.unknown()).optional(), + limit: z.number().positive().optional(), + exclusiveStartKey: z.record(z.string(), z.unknown()).optional(), + indexName: z.string().optional(), + consistentRead: z + .union([z.boolean(), z.enum(['true', 'false'])]) + .optional() + .transform((val) => { + if (val === true || val === 'true') return true + return undefined + }), + segment: z.number().int().min(0).max(999999).optional(), + totalSegments: z.number().int().min(1).max(1000000).optional(), + }) + .superRefine((value, ctx) => { + if ((value.segment === undefined) !== (value.totalSegments === undefined)) { + ctx.addIssue({ + code: 'custom', + path: ['segment'], + message: 'Segment and Total Segments must be set together for a parallel scan', + }) + } else if ( + value.segment !== undefined && + value.totalSegments !== undefined && + value.segment >= value.totalSegments + ) { + ctx.addIssue({ + code: 'custom', + path: ['segment'], + message: 'Segment must be less than Total Segments', + }) + } + }) const ScanResponseSchema = z.object({ message: z.string(), // untyped-response: DynamoDB Items are arbitrary user attribute-value records items: z.array(z.record(z.string(), z.unknown())), count: z.number(), + scannedCount: z.number().nullable(), // untyped-response: DynamoDB LastEvaluatedKey mirrors the table's primary key shape lastEvaluatedKey: z.record(z.string(), z.unknown()).optional(), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-update.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-update.ts index 0b5e2c81ed7..cab57218d3c 100644 --- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-update.ts +++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-update.ts @@ -24,6 +24,7 @@ const UpdateSchema = z.object({ expressionAttributeNames: z.record(z.string(), z.string()).optional(), expressionAttributeValues: z.record(z.string(), z.unknown()).optional(), conditionExpression: z.string().optional(), + returnValues: z.enum(['NONE', 'ALL_OLD', 'UPDATED_OLD', 'ALL_NEW', 'UPDATED_NEW']).optional(), }) const UpdateResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts b/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts index ab70e1d0df4..432b43547a3 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts @@ -3,8 +3,10 @@ import { iamAssumeRolePolicyDocumentSchema, iamConnectionShape, iamCreatePathSchema, + iamPolicyArnSchema, iamRoleDescriptionSchema, iamRoleNameSchema, + iamTagsJsonSchema, } from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, @@ -26,6 +28,8 @@ const Schema = z.object({ .max(43200, 'Max session duration cannot exceed 43200 seconds (12 hours)') .optional() .nullable(), + permissionsBoundary: iamPolicyArnSchema.optional().nullable(), + tags: iamTagsJsonSchema.optional().nullable(), }) const CreateRoleResponseSchema = z.object({ @@ -35,6 +39,13 @@ const CreateRoleResponseSchema = z.object({ arn: z.string(), path: z.string(), createDate: z.string().nullable(), + permissionsBoundaryArn: z.string().nullable(), + tags: z.array( + z.object({ + key: z.string(), + value: z.string(), + }) + ), }) export const awsIamCreateRoleContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts b/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts index 4e252e679b0..fc91d4255e7 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts @@ -2,6 +2,8 @@ import { z } from 'zod' import { iamConnectionShape, iamCreatePathSchema, + iamPolicyArnSchema, + iamTagsJsonSchema, iamUserName64Schema, } from '@/lib/api/contracts/tools/aws/iam-shared' import type { @@ -15,6 +17,8 @@ const Schema = z.object({ ...iamConnectionShape, userName: iamUserName64Schema, path: iamCreatePathSchema.optional().nullable(), + permissionsBoundary: iamPolicyArnSchema.optional().nullable(), + tags: iamTagsJsonSchema.optional().nullable(), }) const CreateUserResponseSchema = z.object({ @@ -24,6 +28,13 @@ const CreateUserResponseSchema = z.object({ arn: z.string(), path: z.string(), createDate: z.string().nullable(), + permissionsBoundaryArn: z.string().nullable(), + tags: z.array( + z.object({ + key: z.string(), + value: z.string(), + }) + ), }) export const awsIamCreateUserContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts b/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts index 8ee1d93e5c7..dfebcef7ff8 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts @@ -23,6 +23,13 @@ const GetRoleResponseSchema = z.object({ assumeRolePolicyDocument: z.string().nullable(), roleLastUsedDate: z.string().nullable(), roleLastUsedRegion: z.string().nullable(), + permissionsBoundaryArn: z.string().nullable(), + tags: z.array( + z.object({ + key: z.string(), + value: z.string(), + }) + ), }) export const awsIamGetRoleContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts b/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts index 42d0f5a7bba..6d90944c4a2 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts @@ -21,6 +21,7 @@ const Schema = z.object({ pathPrefix: iamPolicyPathPrefixSchema.optional().nullable(), maxItems: iamMaxItemsSchema.optional().nullable(), marker: iamMarkerSchema.optional().nullable(), + policyUsageFilter: z.enum(['PermissionsPolicy', 'PermissionsBoundary']).optional().nullable(), }) /** diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts b/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts index 26559165348..a5036b95a06 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' import { validateAwsRegion } from '@/lib/core/security/input-validation' @@ -266,3 +267,22 @@ export const iamPaginationResponseShape = { marker: z.string().nullable(), count: z.number(), } + +/** IAM tags as a JSON object string: at most 50 pairs with string, number, or boolean values. */ +export const iamTagsJsonSchema = z.string().refine( + (value) => { + try { + const parsed: unknown = JSON.parse(value) + return ( + isRecordLike(parsed) && + Object.keys(parsed).length <= 50 && + Object.values(parsed).every((tagValue) => + ['string', 'number', 'boolean'].includes(typeof tagValue) + ) + ) + } catch { + return false + } + }, + { message: 'Tags must be a JSON object of at most 50 key/value pairs with scalar values' } +) diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts index 66915a7dbc5..cd435236ded 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts @@ -24,6 +24,8 @@ const ResponseSchema = z.object({ email: z.string().nullable(), userStatus: z.string().nullable(), title: z.string().nullable(), + givenName: z.string().nullable(), + familyName: z.string().nullable(), externalIds: z.array(z.object({ issuer: z.string(), id: z.string() })), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts index a4caa36bc6d..6dbe279c61d 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts @@ -20,6 +20,7 @@ const ResponseSchema = z.object({ groupId: z.string(), displayName: z.string().nullable(), description: z.string().nullable(), + externalIds: z.array(z.object({ issuer: z.string(), id: z.string() })), }) export const awsIdentityCenterGetGroupContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts index 8ed5810ef28..7d680dbe015 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts @@ -21,6 +21,11 @@ const ResponseSchema = z.object({ userName: z.string(), displayName: z.string().nullable(), email: z.string().nullable(), + givenName: z.string().nullable(), + familyName: z.string().nullable(), + userStatus: z.string().nullable(), + title: z.string().nullable(), + externalIds: z.array(z.object({ issuer: z.string(), id: z.string() })), }) export const awsIdentityCenterGetUserContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts index a1c19b2cf47..2b14ab09b7d 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { + identityCenterAccountIdSchema, identityCenterConnectionShape, identityCenterInstanceArnSchema, identityCenterMaxResultsSchema, @@ -21,6 +22,7 @@ const Schema = z.object({ principalType: identityCenterPrincipalTypeSchema, maxResults: identityCenterMaxResultsSchema.optional(), nextToken: identityCenterNextTokenSchema.optional(), + accountId: identityCenterAccountIdSchema.optional(), }) const ResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/lambda-get-function.ts b/apps/sim/lib/api/contracts/tools/aws/lambda-get-function.ts index ed5fc8369fd..48943d87b4f 100644 --- a/apps/sim/lib/api/contracts/tools/aws/lambda-get-function.ts +++ b/apps/sim/lib/api/contracts/tools/aws/lambda-get-function.ts @@ -37,6 +37,9 @@ const GetFunctionResponseSchema = z.object({ imageUri: z.string().nullable(), resolvedImageUri: z.string().nullable(), sourceKmsKeyArn: z.string().nullable(), + error: z + .object({ errorCode: z.string().nullable(), message: z.string().nullable() }) + .nullable(), }) .nullable(), tags: z.record(z.string(), z.string()), diff --git a/apps/sim/lib/api/contracts/tools/aws/lambda-invoke.ts b/apps/sim/lib/api/contracts/tools/aws/lambda-invoke.ts index e711f7a8c99..7bb519a4e82 100644 --- a/apps/sim/lib/api/contracts/tools/aws/lambda-invoke.ts +++ b/apps/sim/lib/api/contracts/tools/aws/lambda-invoke.ts @@ -25,6 +25,7 @@ const InvokeSchema = z.object({ .min(1, 'qualifier cannot be empty') .max(128, 'qualifier cannot exceed 128 characters') .optional(), + tenantId: z.string().min(1).max(256, 'tenantId cannot exceed 256 characters').optional(), }) const InvokeResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/lambda-shared.ts b/apps/sim/lib/api/contracts/tools/aws/lambda-shared.ts index 52063da1188..226a4bc9ad9 100644 --- a/apps/sim/lib/api/contracts/tools/aws/lambda-shared.ts +++ b/apps/sim/lib/api/contracts/tools/aws/lambda-shared.ts @@ -181,6 +181,7 @@ export const lambdaFunctionConfigurationSchema = z.object({ .object({ retentionPeriodInDays: z.number().nullable(), executionTimeout: z.number().nullable(), + kmsKeyArn: z.string().nullable(), }) .nullable(), tenancyConfig: z.object({ tenantIsolationMode: z.string().nullable() }).nullable(), diff --git a/apps/sim/lib/api/contracts/tools/aws/lambda-update-function-configuration.ts b/apps/sim/lib/api/contracts/tools/aws/lambda-update-function-configuration.ts index 5bb9715627e..2df35a3f8e2 100644 --- a/apps/sim/lib/api/contracts/tools/aws/lambda-update-function-configuration.ts +++ b/apps/sim/lib/api/contracts/tools/aws/lambda-update-function-configuration.ts @@ -34,6 +34,8 @@ const UpdateFunctionConfigurationSchema = z snapStartApplyOn: z.enum(['PublishedVersions', 'None']).optional(), logFormat: z.enum(['JSON', 'Text']).optional(), logGroup: z.string().optional(), + applicationLogLevel: z.enum(['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL']).optional(), + systemLogLevel: z.enum(['DEBUG', 'INFO', 'WARN']).optional(), revisionId: z.string().optional(), }) .superRefine((value, ctx) => { diff --git a/apps/sim/lib/api/contracts/tools/aws/s3-list-objects.ts b/apps/sim/lib/api/contracts/tools/aws/s3-list-objects.ts index b0779cb251a..4ecf45b111c 100644 --- a/apps/sim/lib/api/contracts/tools/aws/s3-list-objects.ts +++ b/apps/sim/lib/api/contracts/tools/aws/s3-list-objects.ts @@ -14,6 +14,8 @@ const S3ListObjectsSchema = z.object({ prefix: z.string().optional().nullable(), maxKeys: z.number().optional().nullable(), continuationToken: z.string().optional().nullable(), + delimiter: z.string().optional().nullable(), + startAfter: z.string().optional().nullable(), }) const S3ListObjectsResponseSchema = z.object({ @@ -25,8 +27,10 @@ const S3ListObjectsResponseSchema = z.object({ size: z.number(), lastModified: z.string(), etag: z.string(), + storageClass: z.string().nullable(), }) ), + commonPrefixes: z.array(z.string()), isTruncated: z.boolean().optional(), nextContinuationToken: z.string().optional(), keyCount: z.number().optional(), diff --git a/apps/sim/lib/api/contracts/tools/aws/s3-put-object.ts b/apps/sim/lib/api/contracts/tools/aws/s3-put-object.ts index 26abb5ef81d..0128b46b9bc 100644 --- a/apps/sim/lib/api/contracts/tools/aws/s3-put-object.ts +++ b/apps/sim/lib/api/contracts/tools/aws/s3-put-object.ts @@ -17,6 +17,10 @@ const S3PutObjectSchema = z.object({ content: z.string().optional().nullable(), contentType: z.string().optional().nullable(), acl: z.string().optional().nullable(), + metadata: z.record(z.string(), z.string()).optional().nullable(), + storageClass: z.string().optional().nullable(), + serverSideEncryption: z.string().optional().nullable(), + sseKmsKeyId: z.string().optional().nullable(), }) const S3PutObjectResponseSchema = z.object({ @@ -28,6 +32,8 @@ const S3PutObjectResponseSchema = z.object({ location: z.string(), key: z.string(), bucket: z.string(), + versionId: z.string().nullable(), + serverSideEncryption: z.string().nullable(), }), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-create-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-create-secret.ts index 87d4fe316ba..efe2d7ddbb6 100644 --- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-create-secret.ts +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-create-secret.ts @@ -13,6 +13,11 @@ const CreateSecretSchema = z.object({ name: z.string().min(1, 'Secret name is required'), secretValue: z.string().min(1, 'Secret value is required'), description: z.string().nullish(), + kmsKeyId: z.string().nullish(), + tags: z + .array(z.object({ key: z.string().min(1).max(128), value: z.string().max(256) })) + .max(50) + .nullish(), }) const CreateSecretResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-get-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-get-secret.ts index 6873afe8fc6..86000552c31 100644 --- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-get-secret.ts +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-get-secret.ts @@ -18,6 +18,7 @@ const GetSecretSchema = z.object({ const GetSecretResponseSchema = z.object({ name: z.string(), secretValue: z.string(), + secretBinary: z.string().nullable(), arn: z.string(), versionId: z.string(), versionStages: z.array(z.string()), diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts index 198e751949a..2ec15fbb049 100644 --- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts @@ -12,6 +12,26 @@ const ListSecretsSchema = z.object({ secretAccessKey: z.string().min(1, 'AWS secret access key is required'), maxResults: z.number().min(1).max(100).nullish(), nextToken: z.string().nullish(), + filters: z + .array( + z.object({ + key: z.enum([ + 'description', + 'name', + 'tag-key', + 'tag-value', + 'primary-region', + 'owning-service', + 'all', + ]), + values: z.array(z.string().min(1)).min(1).max(10), + }) + ) + .max(10) + .nullish(), + sortOrder: z.enum(['asc', 'desc']).nullish(), + sortBy: z.enum(['created-date', 'last-accessed-date', 'last-changed-date', 'name']).nullish(), + includePlannedDeletion: z.boolean().nullish(), }) const RotationRulesResponseSchema = z.object({ @@ -26,6 +46,10 @@ const ListSecretsResponseSchema = z.object({ name: z.string(), arn: z.string(), description: z.string().nullable(), + kmsKeyId: z.string().nullable(), + rotationLambdaARN: z.string().nullable(), + owningService: z.string().nullable(), + primaryRegion: z.string().nullable(), createdDate: z.string().nullable(), lastChangedDate: z.string().nullable(), lastAccessedDate: z.string().nullable(), diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-update-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-update-secret.ts index e72fe04a3ad..a7d35a0a5ee 100644 --- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-update-secret.ts +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-update-secret.ts @@ -13,6 +13,7 @@ const UpdateSecretSchema = z.object({ secretId: z.string().min(1, 'Secret ID is required'), secretValue: z.string().min(1, 'Secret value is required'), description: z.string().nullish(), + kmsKeyId: z.string().nullish(), }) const UpdateSecretResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-get-account.ts b/apps/sim/lib/api/contracts/tools/aws/ses-get-account.ts index c8555b7086a..ee528d689bc 100644 --- a/apps/sim/lib/api/contracts/tools/aws/ses-get-account.ts +++ b/apps/sim/lib/api/contracts/tools/aws/ses-get-account.ts @@ -23,6 +23,8 @@ const GetAccountResponseSchema = z.object({ max24HourSend: z.number(), maxSendRate: z.number(), sentLast24Hours: z.number(), + productionAccessEnabled: z.boolean().nullable(), + enforcementStatus: z.string().nullable(), }) export const awsSesGetAccountContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-send-bulk-email.ts b/apps/sim/lib/api/contracts/tools/aws/ses-send-bulk-email.ts index 06cc349435f..6288096bd1f 100644 --- a/apps/sim/lib/api/contracts/tools/aws/ses-send-bulk-email.ts +++ b/apps/sim/lib/api/contracts/tools/aws/ses-send-bulk-email.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { sesEmailTagsSchema } from '@/lib/api/contracts/tools/aws/ses-shared' import type { ContractBody, ContractBodyInput, @@ -21,6 +22,9 @@ const SendBulkEmailSchema = z.object({ destinations: z.string().min(1, 'Destinations JSON array is required'), defaultTemplateData: z.string().nullish(), configurationSetName: z.string().nullish(), + replyToAddresses: z.string().nullish(), + fromName: z.string().nullish(), + emailTags: sesEmailTagsSchema, }) const SendBulkEmailResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-send-email.ts b/apps/sim/lib/api/contracts/tools/aws/ses-send-email.ts index f6f93286d4a..968f4e7c57b 100644 --- a/apps/sim/lib/api/contracts/tools/aws/ses-send-email.ts +++ b/apps/sim/lib/api/contracts/tools/aws/ses-send-email.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { sesEmailTagsSchema } from '@/lib/api/contracts/tools/aws/ses-shared' import type { ContractBody, ContractBodyInput, @@ -26,6 +27,8 @@ const SendEmailSchema = z bccAddresses: z.string().nullish(), replyToAddresses: z.string().nullish(), configurationSetName: z.string().nullish(), + fromName: z.string().nullish(), + emailTags: sesEmailTagsSchema, }) .refine((data) => data.bodyText || data.bodyHtml, { message: 'At least one of bodyText or bodyHtml is required', diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-send-templated-email.ts b/apps/sim/lib/api/contracts/tools/aws/ses-send-templated-email.ts index 589316675d2..bb46f9ac822 100644 --- a/apps/sim/lib/api/contracts/tools/aws/ses-send-templated-email.ts +++ b/apps/sim/lib/api/contracts/tools/aws/ses-send-templated-email.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { sesEmailTagsSchema } from '@/lib/api/contracts/tools/aws/ses-shared' import type { ContractBody, ContractBodyInput, @@ -23,6 +24,9 @@ const SendTemplatedEmailSchema = z.object({ ccAddresses: z.string().nullish(), bccAddresses: z.string().nullish(), configurationSetName: z.string().nullish(), + replyToAddresses: z.string().nullish(), + fromName: z.string().nullish(), + emailTags: sesEmailTagsSchema, }) const SendTemplatedEmailResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-shared.ts b/apps/sim/lib/api/contracts/tools/aws/ses-shared.ts new file mode 100644 index 00000000000..65624ddb600 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-shared.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +/** SES `MessageTag` name or value: 1-256 letters, digits, underscores, or hyphens. */ +const sesMessageTagPartSchema = z + .string() + .regex(/^[A-Za-z0-9_-]{1,256}$/, 'Tag names and values may contain only letters, digits, _ and -') + +/** Message tags on an SES send, for filtering its sending events. */ +export const sesEmailTagsSchema = z + .array(z.object({ name: sesMessageTagPartSchema, value: sesMessageTagPartSchema })) + .nullish() diff --git a/apps/sim/lib/api/contracts/tools/confluence.ts b/apps/sim/lib/api/contracts/tools/confluence.ts index 482f7e67e3e..71c149e8ce4 100644 --- a/apps/sim/lib/api/contracts/tools/confluence.ts +++ b/apps/sim/lib/api/contracts/tools/confluence.ts @@ -111,12 +111,14 @@ export const confluenceListAttachmentsQuerySchema = confluencePageScopedSchema.e export const confluenceCreateCommentBodySchema = confluencePageScopedSchema.extend({ comment: z.string({ error: 'Comment is required' }).min(1, 'Comment is required'), + parentCommentId: z.string().optional(), }) export const confluenceListCommentsQuerySchema = confluencePageScopedSchema.extend({ limit: z.string().optional().default('25'), bodyFormat: z.string().optional().default('storage'), cursor: z.string().optional(), + commentType: z.enum(['footer', 'inline']).optional(), }) export const confluenceUpdateCommentBodySchema = confluenceCommentScopedBaseSchema @@ -196,6 +198,8 @@ export const confluencePagesByLabelQuerySchema = confluenceBaseSchema.extend({ export const confluenceSearchBodySchema = confluenceBaseSchema.extend({ query: z.string({ error: 'Search query is required' }).min(1, 'Search query is required'), limit: z.number().optional().default(25), + cql: z.string().optional(), + cursor: z.string().optional(), }) export const confluenceSearchInSpaceBodySchema = confluenceBaseSchema.extend({ @@ -203,6 +207,7 @@ export const confluenceSearchInSpaceBodySchema = confluenceBaseSchema.extend({ query: z.string().optional(), limit: z.number().optional().default(25), contentType: z.string().optional(), + cursor: z.string().optional(), }) export const confluenceSpaceBlogPostsBodySchema = confluenceSpaceScopedSchema.extend({ diff --git a/apps/sim/lib/api/contracts/tools/google.ts b/apps/sim/lib/api/contracts/tools/google.ts index 024c6870bc8..6519bfb3c9c 100644 --- a/apps/sim/lib/api/contracts/tools/google.ts +++ b/apps/sim/lib/api/contracts/tools/google.ts @@ -30,6 +30,7 @@ export const gmailMailBodySchema = z.object({ replyToMessageId: z.string().optional().nullable(), cc: z.string().optional().nullable(), bcc: z.string().optional().nullable(), + replyTo: z.string().optional().nullable(), attachments: RawFileInputArraySchema.optional().nullable(), }) diff --git a/apps/sim/lib/api/contracts/tools/jsm.ts b/apps/sim/lib/api/contracts/tools/jsm.ts index da5d2b74442..876ecfafdba 100644 --- a/apps/sim/lib/api/contracts/tools/jsm.ts +++ b/apps/sim/lib/api/contracts/tools/jsm.ts @@ -81,6 +81,8 @@ export const jsmRequestsBodySchema = jsmBaseBodySchema.extend({ serviceDeskId: z.string().optional(), requestOwnership: z.string().optional(), requestStatus: z.string().optional(), + approvalStatus: z.string().optional(), + organizationId: z.string().optional(), requestTypeId: z.string().optional(), searchTerm: z.string().optional(), expand: z.string().optional(), diff --git a/apps/sim/lib/api/contracts/tools/microsoft.ts b/apps/sim/lib/api/contracts/tools/microsoft.ts index 4b4889b34c4..5ea5a6c48c8 100644 --- a/apps/sim/lib/api/contracts/tools/microsoft.ts +++ b/apps/sim/lib/api/contracts/tools/microsoft.ts @@ -16,6 +16,8 @@ export const outlookSendBodySchema = z.object({ cc: z.string().optional().nullable(), bcc: z.string().optional().nullable(), replyToMessageId: z.string().optional().nullable(), + replyTo: z.string().optional().nullable(), + importance: z.enum(['low', 'normal', 'high']).optional().nullable(), attachments: RawFileInputArraySchema.optional().nullable(), }) diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts index 59167a71c39..67942cb26d6 100644 --- a/apps/sim/lib/api/contracts/v2/catalog.ts +++ b/apps/sim/lib/api/contracts/v2/catalog.ts @@ -696,6 +696,12 @@ export const v2ConnectorConfigFieldSchema = z .describe( 'When true the stored `sourceConfig` value is a `string[]`, not a `string`: a `selector` renders a multi-select picker and a `short-input` accepts a comma-separated list.' ), + apiKeyOnly: z + .boolean() + .optional() + .describe( + 'When true the field describes the connector’s API key (such as its key ID or a custom host the key signs for). It is required as stated only with `apiKey`, and omitted when a saved `credentialId` is sent.' + ), }) .meta({ id: 'V2ConnectorConfigField', @@ -731,7 +737,7 @@ export const v2ConnectorTypeSchema = z mode: z .literal('apiKey') .describe( - 'Authenticates with a stored API key; pass `apiKey`, and no `credentialId`, when creating the connector.' + 'Authenticates with a stored API key; pass `apiKey` when creating the connector, or a saved `credentialId` instead when `savedCredential` is present.' ), label: z.string().optional().describe('Label shown above the key field.'), placeholder: z.string().optional().describe('Placeholder shown in the key field.'), @@ -740,10 +746,21 @@ export const v2ConnectorTypeSchema = z .describe( 'Whether the key may be left blank, for a source reachable without authentication.' ), + savedCredential: z + .object({ + provider: z + .string() + .describe('Service the saved credential must authenticate with, e.g. `aws`.'), + label: z.string().describe('Label for the saved-credential option.'), + }) + .optional() + .describe( + 'When present, a saved credential of this service may be passed as `credentialId` instead of `apiKey`, once saved-credential setup is enabled on this deployment (until then it is refused with a validation error). Fields marked `apiKeyOnly` are then omitted.' + ), }), ]) .describe( - 'How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey`.' + 'How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey` (or, when they declare `savedCredential`, a saved `credentialId`).' ), configFields: z .array(v2ConnectorConfigFieldSchema) @@ -787,7 +804,7 @@ export const v2ConnectorTypeSummarySchema = z mode: z .enum(['oauth', 'apiKey']) .describe( - 'How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey`.' + 'How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey` (or, when they declare `savedCredential`, a saved `credentialId`).' ), }) .describe( diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 0d61cf7712e..f6f4493764e 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -107,6 +107,12 @@ export const v2CredentialProviderFieldSchema = z .max(20) .optional() .describe('Fixed values accepted by a selector field.'), + defaultValue: z + .string() + .min(1) + .max(255) + .optional() + .describe('Value an optional field takes when it is omitted.'), hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), }) .strict() @@ -167,7 +173,9 @@ export const v2ServiceAccountCredentialProviderSchema = z requiredConfigFields: z.array(z.string()).describe('Fields you add to `keylessConfig`.'), optionalConfigFields: z .array(z.string()) - .describe('Fields `keylessConfig` may also carry.'), + .describe( + 'Fields `keylessConfig` may also carry. `audience` is a custom `aud` claim Sim signs in place of the default; your trust configuration must then accept it.' + ), }) .strict() .optional() @@ -305,6 +313,7 @@ export const V2_OAUTH_CONNECTION_PROVIDER_IDS = [ 'ramp', 'helpscout', 'hubspot', + 'apollo', 'linkedin', 'instagram', 'salesforce', @@ -449,20 +458,24 @@ const v2CredentialWorkloadIdentitySubjectSchema = z }) .strict() .describe( - 'Prefilled keyless `serviceAccountJson`: add `requiredConfigFields`, then send it to Create service-account credential.' + 'Prefilled keyless `serviceAccountJson`: add `requiredConfigFields` (and any `optionalConfigFields`, such as a custom `audience`), then send it to Create service-account credential.' ), requiredConfigFields: z .array(z.string()) .describe( 'Fields from your cloud setup that `keylessConfig` still needs, such as roleArn or host.' ), - optionalConfigFields: z.array(z.string()).describe('Fields `keylessConfig` may also carry.'), + optionalConfigFields: z + .array(z.string()) + .describe( + 'Fields `keylessConfig` may also carry. `audience` replaces the default `audience` above with your own value, such as one specific to your account; trust that value instead of the default.' + ), }) .meta({ id: 'V2CredentialWorkloadIdentitySubject', title: 'Credential workload identity subject', description: - 'The issuer, audience, and subject a cloud trust configuration pins, and the proof that attaches the subject to a new keyless credential.', + 'The issuer, default audience, and subject a cloud trust configuration pins, and the proof that attaches the subject to a new keyless credential. A `keylessConfig.audience` overrides the default audience.', }) export const v2CreateCredentialWorkloadIdentitySubjectContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 2aab9f6129c..e09ab863dca 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -1777,7 +1777,9 @@ export const v2KnowledgeConnectorSchema = z credentialId: z .string() .nullable() - .describe('OAuth credential identifier, or null for API-key and unauthenticated sources.'), + .describe( + 'OAuth credential identifier, or the saved credential an `apiKey` source syncs with (see `auth.savedCredential`); null for unauthenticated sources and for `apiKey` sources that use an inline API key.' + ), sourceConfig: z .record(z.string(), z.unknown().describe('Connector-specific source configuration value.')) .describe('Connector-specific source selection and filtering configuration.'), @@ -1932,7 +1934,7 @@ export const v2CreateKnowledgeConnectorBodySchema = z .max(255) .optional() .describe( - 'OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types); omit it for `apiKey` connectors.' + 'OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.' ), apiKey: z .string() diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index f5847eac519..e657a31519f 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -86,15 +86,15 @@ const v2LogDetailCostSchema = z * * That pass-through is also why this field disagrees with the run resources for the * same run, and the disagreement is documented rather than reconciled. The run list - * projects `paused` over the persisted value whenever the run holds a `paused` or - * `partially_resumed` row in `paused_executions` (`executionStatus` in - * `lib/workflows/executor/execution-queries.ts`), so an ordinary human-in-the-loop + * projects `paused` over the persisted value while the run holds a `paused` or + * `partially_resumed` row in `paused_executions` with a point still to resume + * (`executionStatus` in `lib/workflows/executor/execution-queries.ts`), so an ordinary human-in-the-loop * pause reads `paused` there and `pending` here. Adopting the overlay would need this * read to join `paused_executions`, and would silently move live runs between the * `pending` and `paused` buckets of a shipped field that internal log consumers read * from the same query — a breaking change, not a correction. Callers that need the - * pause distinction read the run resources, which also carry the `paused` object that - * separates "waiting on a human" from "a resume attempt failed". + * pause distinction read the run resources, which also carry the `paused` object with the + * pause kind, resume time, and any automatic-resume waiting reason. */ export const v2LogStatusSchema = z .enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index a24849f18fd..9b9a66c2a73 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -625,7 +625,7 @@ const WORKLOAD_IDENTITY_SUBJECT_EXAMPLE = { subjectProof: WORKLOAD_IDENTITY_SUBJECT_PROOF, }, requiredConfigFields: ['roleArn'], - optionalConfigFields: ['stsRegion', 'sessionDurationSeconds'], + optionalConfigFields: ['stsRegion', 'sessionDurationSeconds', 'audience'], } as const const SECRET_EXAMPLE = { @@ -1840,7 +1840,7 @@ const declaredRoutes = [ applicationOperation: credentialOperations.prepareWorkloadIdentity, operationId: 'createCredentialWorkloadIdentitySubject', summary: 'Create workload identity subject', - description: `Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus \`keylessConfig\`: a prefilled \`serviceAccountJson\` whose \`subjectProof\` is valid for 24 hours and only for the requesting user. Add the \`requiredConfigFields\`, then call Create service-account credential with the same \`providerId\`; Sim verifies the trust while saving. Google Cloud has no fixed audience: it is the credential's own pool provider. Without keyless support the deployment returns \`400\` with \`details.code\` \`WORKLOAD_IDENTITY_NOT_CONFIGURED\`. ${WORKSPACE_API_KEY_DENIED}`, + description: `Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus \`keylessConfig\`: a prefilled \`serviceAccountJson\` whose \`subjectProof\` is valid for 24 hours and only for the requesting user. Add the \`requiredConfigFields\`, then call Create service-account credential with the same \`providerId\`; Sim verifies the trust while saving. The returned \`audience\` is the default; set \`audience\` in \`keylessConfig\` to have Sim sign tokens for your own value instead, and trust that value in the cloud. Google Cloud has no fixed default: it is the credential's own pool provider. Without keyless support the deployment returns \`400\` with \`details.code\` \`WORKLOAD_IDENTITY_NOT_CONFIGURED\`. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'A subject and proof for one new keyless credential.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 0aa62b0001d..ce9558a9b7e 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1983,16 +1983,17 @@ export const v2ResumeWorkflowContract = defineRouteContract({ }) const RUN_STATUS_DESCRIPTION = - "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting at a human or time pause, or a resume attempt did not complete. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason." + "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run has a human or time pause that can still be resumed; once the run has finished and every pause point has resumed or failed, it reports the finished status. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason." /** * The list projection passes `workflow_execution_logs.status` through except where it * overlays `paused` for a run holding a `paused` or `partially_resumed` row in - * `paused_executions` — so a reported `paused` is either that overlay or the persisted - * value a failed resume attempt left behind. Both branches land in the persisted set, so the reported enum is - * derived from it — a value missing here fails the response parse, and because list - * validation is whole-page one such row turns an entire page into a 500. `queued` is not - * reportable: a run still only in the job queue has no log row to list. + * `paused_executions` that still has a point to resume (a finished run whose every point + * resumed or failed reads its log status). A reported `paused` is either that overlay or the + * persisted value a failed resume attempt left behind. Both branches land in the persisted + * set, so the reported enum is derived from it — a value missing here fails the response + * parse, and because list validation is whole-page one such row turns an entire page into a + * 500. `queued` is not reportable: a run still only in the job queue has no log row to list. */ export const v2WorkflowRunListStatusValueSchema = z .enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES) diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 1b04a5f6016..bf693ccfc18 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -665,7 +665,7 @@ export const V2_MCP_OPERATIONS = { contract: v2CreateCredentialWorkloadIdentitySubjectContract, summary: 'Create workload identity subject', description: - "Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus `keylessConfig`: a prefilled `serviceAccountJson` whose `subjectProof` is valid for 24 hours and only for the requesting user. Add the `requiredConfigFields`, then call Create service-account credential with the same `providerId`; Sim verifies the trust while saving. Google Cloud has no fixed audience: it is the credential's own pool provider. Without keyless support the deployment returns `400` with `details.code` `WORKLOAD_IDENTITY_NOT_CONFIGURED`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus `keylessConfig`: a prefilled `serviceAccountJson` whose `subjectProof` is valid for 24 hours and only for the requesting user. Add the `requiredConfigFields`, then call Create service-account credential with the same `providerId`; Sim verifies the trust while saving. The returned `audience` is the default; set `audience` in `keylessConfig` to have Sim sign tokens for your own value instead, and trust that value in the cloud. Google Cloud has no fixed default: it is the credential's own pool provider. Without keyless support the deployment returns `400` with `details.code` `WORKLOAD_IDENTITY_NOT_CONFIGURED`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/credentials/workload-identity/subjects/route').then( diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 31ea8978ad5..0b492da2045 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -5,6 +5,7 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger, setRequestAuth } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { APIError, @@ -159,6 +160,7 @@ import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' +import { formatApolloAuthorizationUrl } from '@/lib/oauth/apollo' import { clearRecordedRevocation } from '@/lib/oauth/credential-service' import { getMicrosoftRefreshTokenExpiry, @@ -1242,6 +1244,13 @@ export const auth = betterAuth({ } }), after: createAuthMiddleware(async (ctx) => { + if (ctx.path === '/oauth2/link' && ctx.body?.providerId === 'apollo') { + const returned = ctx.context.returned + if (isRecordLike(returned) && typeof returned.url === 'string') { + ctx.context.returned = { ...returned, url: formatApolloAuthorizationUrl(returned.url) } + } + } + if (isBillingEnabled) await recordCustomerRestoreAfterHook(ctx) const recordFinalAuthenticationResult = async ( diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts index 4e9b89522ac..29b8290cb8d 100644 --- a/apps/sim/lib/auth/connectors/providers.ts +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -15,6 +15,7 @@ import { readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' import { getBaseUrl } from '@/lib/core/utils/urls' +import { createApolloProvider } from '@/lib/oauth/apollo' import { exchangeAtlassianAuthorizationCode } from '@/lib/oauth/atlassian-token' import { getDocusignOAuthUrl } from '@/lib/oauth/docusign' import { createGitHubRepositoriesProvider } from '@/lib/oauth/github-repositories' @@ -1042,6 +1043,13 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { }, }, + createApolloProvider({ + clientId: env.APOLLO_CLIENT_ID as string, + clientSecret: env.APOLLO_CLIENT_SECRET as string, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/apollo`, + scopes: getCanonicalScopesForProvider('apollo'), + }), + { providerId: 'pipedrive', clientId: env.PIPEDRIVE_CLIENT_ID as string, diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index da71750cd50..ceb36948e0a 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -3,7 +3,7 @@ import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { APIError } from 'better-auth/api' import { and, eq, isNotNull } from 'drizzle-orm' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config' import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { @@ -64,8 +64,9 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise * license lookup fails. */ async function assertNotBilledThroughMarketplace(referenceId: string): Promise { - if (!getAwsMarketplaceProductCode()) return - if (!(await holdsMarketplaceLicense(referenceId))) return + const products = getAwsMarketplaceProducts() + if (products.size === 0) return + if (!(await holdsMarketplaceLicense(products, referenceId))) return logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId }) throw new APIError('CONFLICT', { diff --git a/apps/sim/lib/billing/aws-marketplace/application/admin.ts b/apps/sim/lib/billing/aws-marketplace/application/admin.ts index 4154364c62a..ad373adbb8f 100644 --- a/apps/sim/lib/billing/aws-marketplace/application/admin.ts +++ b/apps/sim/lib/billing/aws-marketplace/application/admin.ts @@ -1,9 +1,10 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { AuditAction, recordAudit } from '@sim/audit' import { db } from '@sim/db' import type { AdminMutationActor } from '@/lib/admin/dashboard' import { - licenseAuditMetadata, + licenseAuditEntry, requireLinkableTo, + requireNotBilledThroughStripe, } from '@/lib/billing/aws-marketplace/application/use-cases' import { countUnlinkedLicenses, @@ -57,6 +58,7 @@ export async function linkAwsMarketplaceLicenseAsAdmin( if (locked.organizationId === input.organizationId) { return { license: locked, newlyLinked: false } } + await requireNotBilledThroughStripe(tx, input.organizationId) return { license: await linkLicenseForTeam(tx, input.licenseId, input.organizationId), newlyLinked: true, @@ -67,12 +69,12 @@ export async function linkAwsMarketplaceLicenseAsAdmin( actorId: actor.id, actorName: actor.name, actorEmail: actor.email, - action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, - resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, - resourceId: license.id, - resourceName: license.dimension ?? 'AWS Marketplace license', - description: 'Admin linked an AWS Marketplace subscription to an organization', - metadata: { ...licenseAuditMetadata(license), organizationId: input.organizationId }, + ...licenseAuditEntry( + license, + AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, + 'Admin linked an AWS Marketplace subscription to an organization', + { organizationId: input.organizationId } + ), }) } return { id: license.id, organizationId: input.organizationId } diff --git a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts index 4e1d3ec5383..be2f5774365 100644 --- a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts +++ b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts @@ -1,12 +1,16 @@ -import { AuditAction, AuditResourceType } from '@sim/audit' +import { AuditAction, type AuditActionType, AuditResourceType } from '@sim/audit' import { db } from '@sim/db' import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace' import { awsMarketplaceOperations } from '@/lib/billing/aws-marketplace/application/operations' import { hashClaimToken } from '@/lib/billing/aws-marketplace/claim' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { + findAwsMarketplaceProduct, + isAwsMarketplaceEnabled, +} from '@/lib/billing/aws-marketplace/config' import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' import { type AwsMarketplaceLicenseRecord, + type BuyerContact, consumeLicenseClaim, findLicenseByClaimHash, isNewerRead, @@ -14,6 +18,7 @@ import { listAdministeredOrganizations, listOrganizationLicenses, lockLicense, + paysForSelfServePlan, recordLicenseVerification, saveLicenseContact, } from '@/lib/billing/aws-marketplace/repository' @@ -25,12 +30,18 @@ import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/autho import { assertOperationPrincipal, type OperationUseCase } from '@/lib/core/application/operation' import { requireOrganizationSubjectMembership } from '@/lib/core/application/organization-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' /** The same answer as an unknown route, so a deployment that does not sell on AWS shows nothing. */ -function requireProductCode(): string { - const productCode = getAwsMarketplaceProductCode() - if (!productCode) throw new OrchestrationError('not_found', 'Not found') - return productCode +function requireMarketplaceEnabled(): void { + if (!isAwsMarketplaceEnabled()) throw new OrchestrationError('not_found', 'Not found') +} + +/** The listing a license was sold under; one this deployment no longer sells reads as unknown. */ +function requireLicenseProduct(license: AwsMarketplaceLicenseRecord) { + const product = findAwsMarketplaceProduct(license.productCode) + if (!product) throw new OrchestrationError('not_found', 'Not found') + return product } function isClaimOpen(license: AwsMarketplaceLicenseRecord, now: Date): boolean { @@ -85,6 +96,22 @@ export function requireLinkableTo( } } +/** + * AWS bills a linked organization for the listing, so one already paying Stripe for a plan would + * pay twice. Its subscription is cancelled first, by its owner or by support. + */ +export async function requireNotBilledThroughStripe( + tx: DbTransaction, + organizationId: string +): Promise { + if (await paysForSelfServePlan(tx, organizationId)) { + throw new OrchestrationError( + 'conflict', + 'This organization already pays for a Sim plan. Cancel that subscription, or contact support, before linking an AWS Marketplace subscription to it.' + ) + } +} + /** Enough for a buyer to recognize their account, and no more for someone holding their claim. */ function maskAwsAccount(accountId: string): string { return `••••${accountId.slice(-4)}` @@ -115,7 +142,7 @@ export const readAwsMarketplaceClaim: OperationUseCase< operation: awsMarketplaceOperations.readClaim, async execute({ principal, input }) { assertOperationPrincipal(principal, awsMarketplaceOperations.readClaim) - requireProductCode() + requireMarketplaceEnabled() if (!input.claimToken) return { status: 'missing' } const license = await findLicenseByClaimHash(hashClaimToken(input.claimToken)) if (!license || !isClaimOpen(license, new Date())) return { status: 'missing' } @@ -141,12 +168,6 @@ interface ClaimInput { claimToken: string | null } -interface BuyerContact { - /** Whom to contact about provisioning; AWS asks the registration page to collect it. */ - contactEmail: string - companyName: string | null -} - type TakeInput = ClaimInput & BuyerContact interface LinkLicenseInput extends TakeInput { @@ -159,8 +180,20 @@ interface LinkedLicense { newlyLinked: boolean } -function contactOf(input: BuyerContact) { - return { email: input.contactEmail, companyName: input.companyName } +/** The license this browser's claim names, refused unless the claim is still open. */ +async function findOpenClaim(input: ClaimInput) { + const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null + const claimed = requireOpenClaim( + claimHash ? await findLicenseByClaimHash(claimHash) : null, + input.licenseId, + claimHash + ) + return { claimHash, claimed } +} + +/** Locks the claimed license and checks the claim again, so two tabs racing it take it once. */ +async function lockOpenClaim(tx: DbTransaction, input: ClaimInput, claimHash: string | null) { + return requireOpenClaim(await lockLicense(tx, input.licenseId), input.licenseId, claimHash) } /** @@ -169,22 +202,17 @@ function contactOf(input: BuyerContact) { * rechecks the claim under the row lock, so two tabs racing one claim take it at most once. */ async function verifyClaimedLicense(input: ClaimInput, organizationId: string | null) { - const productCode = requireProductCode() - const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null - const claimed = requireOpenClaim( - claimHash ? await findLicenseByClaimHash(claimHash) : null, - input.licenseId, - claimHash - ) + requireMarketplaceEnabled() + const { claimHash, claimed } = await findOpenClaim(input) requireLinkableTo(claimed, organizationId) const verifiedAt = new Date() - const summary = await verifyLicense(productCode, claimed.licenseArn) + const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn) const verification = { summary, verifiedAt } if (!summary.active) { const outcome = await recordLicenseVerification({ licenseArn: claimed.licenseArn, - productCode, + productCode: claimed.productCode, verification, }) throw new OrchestrationError( @@ -197,11 +225,25 @@ async function verifyClaimedLicense(input: ClaimInput, organizationId: string | return { claimHash, verification } } -export function licenseAuditMetadata(license: AwsMarketplaceLicenseRecord) { +/** The audit entry for a license changing hands; `metadata` adds to its terms. */ +export function licenseAuditEntry( + license: AwsMarketplaceLicenseRecord, + action: AuditActionType, + description: string, + metadata: Record = {} +) { return { - dimension: license.dimension, - quantity: license.quantity, - expiresAt: license.expiresAt?.toISOString() ?? null, + action, + resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, + resourceId: license.id, + resourceName: license.dimension ?? 'AWS Marketplace license', + description, + metadata: { + dimension: license.dimension, + quantity: license.quantity, + expiresAt: license.expiresAt?.toISOString() ?? null, + ...metadata, + }, } } @@ -218,11 +260,7 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({ }: OrganizationUseCaseContext): Promise { const { claimHash, verification } = await verifyClaimedLicense(input, input.organizationId) return db.transaction(async (tx) => { - const locked = requireOpenClaim( - await lockLicense(tx, input.licenseId), - input.licenseId, - claimHash - ) + const locked = await lockOpenClaim(tx, input, claimHash) requireLinkableTo(locked, input.organizationId) requireNotOverruled(locked, verification.verifiedAt) // Holds the admin's membership until commit, so a concurrent demotion waits for the link. @@ -235,9 +273,10 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({ { executor: tx, forShare: true } ) const newlyLinked = locked.organizationId !== input.organizationId + if (newlyLinked) await requireNotBilledThroughStripe(tx, input.organizationId) const license = await consumeLicenseClaim(tx, locked, { link: newlyLinked ? { organizationId: input.organizationId, userId: context.userId } : null, - contact: contactOf(input), + contact: input, verification, }) return { license, newlyLinked } @@ -245,14 +284,11 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({ }, projectAudit: ({ result }) => result.newlyLinked - ? { - action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, - resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, - resourceId: result.license.id, - resourceName: result.license.dimension ?? 'AWS Marketplace license', - description: 'Linked an AWS Marketplace subscription to the organization', - metadata: licenseAuditMetadata(result.license), - } + ? licenseAuditEntry( + result.license, + AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, + 'Linked an AWS Marketplace subscription to the organization' + ) : [], }) @@ -272,17 +308,13 @@ export const registerAwsMarketplaceLicenseToAccount: OperationUseCase< assertOperationPrincipal(principal, awsMarketplaceOperations.registerToAccount) const { claimHash, verification } = await verifyClaimedLicense(input, null) const { license, newlyRegistered } = await db.transaction(async (tx) => { - const locked = requireOpenClaim( - await lockLicense(tx, input.licenseId), - input.licenseId, - claimHash - ) + const locked = await lockOpenClaim(tx, input, claimHash) requireLinkableTo(locked, null) requireNotOverruled(locked, verification.verifiedAt) const newlyRegistered = locked.linkedByUserId !== principal.userId const consumed = await consumeLicenseClaim(tx, locked, { link: newlyRegistered ? { organizationId: null, userId: principal.userId } : null, - contact: contactOf(input), + contact: input, verification, }) return { license: consumed, newlyRegistered } @@ -294,14 +326,11 @@ export const registerAwsMarketplaceLicenseToAccount: OperationUseCase< principal, request, [ - { - action: AuditAction.AWS_MARKETPLACE_LICENSE_REGISTERED, - resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, - resourceId: license.id, - resourceName: license.dimension ?? 'AWS Marketplace license', - description: 'Registered an AWS Marketplace subscription to an account', - metadata: licenseAuditMetadata(license), - }, + licenseAuditEntry( + license, + AuditAction.AWS_MARKETPLACE_LICENSE_REGISTERED, + 'Registered an AWS Marketplace subscription to an account' + ), ] ) } @@ -321,23 +350,14 @@ export const saveAwsMarketplaceClaimContact: OperationUseCase< operation: awsMarketplaceOperations.saveClaimContact, async execute({ principal, input }) { assertOperationPrincipal(principal, awsMarketplaceOperations.saveClaimContact) - const productCode = requireProductCode() - const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null - const claimed = requireOpenClaim( - claimHash ? await findLicenseByClaimHash(claimHash) : null, - input.licenseId, - claimHash - ) + requireMarketplaceEnabled() + const { claimHash, claimed } = await findOpenClaim(input) const verifiedAt = new Date() - const summary = await verifyLicense(productCode, claimed.licenseArn) + const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn) return db.transaction(async (tx) => { - const locked = requireOpenClaim( - await lockLicense(tx, input.licenseId), - input.licenseId, - claimHash - ) + const locked = await lockOpenClaim(tx, input, claimHash) const license = await saveLicenseContact(tx, locked, { - contact: contactOf(input), + contact: input, verification: { summary, verifiedAt }, }) return { status: license.status } @@ -360,7 +380,7 @@ function toLicenseStatusList(rows: Awaited) { - if (!getAwsMarketplaceProductCode()) return [] + if (!isAwsMarketplaceEnabled()) return [] return toLicenseStatusList(await listOrganizationLicenses(context.organizationId)) }, }) @@ -374,7 +394,7 @@ export const listAccountAwsMarketplaceLicenses: OperationUseCase< operation: awsMarketplaceOperations.listAccountLicenses, async execute({ principal }) { assertOperationPrincipal(principal, awsMarketplaceOperations.listAccountLicenses) - if (!getAwsMarketplaceProductCode()) return [] + if (!isAwsMarketplaceEnabled()) return [] return toLicenseStatusList(await listAccountLicenses(principal.userId)) }, } diff --git a/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts index 4a7ff2aeaa5..b69b1df157f 100644 --- a/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts +++ b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts @@ -4,15 +4,20 @@ import { generateId } from '@sim/utils/id' import { inArray } from 'drizzle-orm' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' -const PRODUCT = 'product-placeholder' const ACCOUNT = '000000000000' const TEAM_PLAN = 'team_6000' +const RETIRED_PRODUCT = 'retired-product-placeholder' -const productCode = vi.hoisted(() => ({ current: 'product-placeholder' as string | null })) +const marketplace = vi.hoisted(() => ({ enabled: true, product: 'product-placeholder' })) +const PRODUCT = marketplace.product -vi.mock('@/lib/billing/aws-marketplace/config', () => ({ - getAwsMarketplaceProductCode: () => productCode.current, -})) +vi.mock('@/lib/billing/aws-marketplace/config', async () => { + const { parseAwsMarketplaceProducts } = await vi.importActual< + typeof import('@/lib/billing/aws-marketplace/products') + >('@/lib/billing/aws-marketplace/products') + const sold = parseAwsMarketplaceProducts(`${marketplace.product}=contract`) + return { getAwsMarketplaceProducts: () => (marketplace.enabled ? sold : new Map()) } +}) import { assertPersonalCheckoutAllowed, @@ -61,12 +66,13 @@ async function insertLicense(link: { organizationId: string | null linkedByUserId: string status: LicenseStatus + productCode?: string }) { const now = new Date() await db.insert(awsMarketplaceLicense).values({ id: generateId(), licenseArn: `license-${generateId()}`, - productCode: PRODUCT, + productCode: link.productCode ?? PRODUCT, customerAwsAccountId: ACCOUNT, dimension: 'enterprise', quantity: 25, @@ -84,7 +90,7 @@ async function organizationWithLicense(status: LicenseStatus | null) { } afterEach(() => { - productCode.current = PRODUCT + marketplace.enabled = true }) afterAll(async () => { @@ -135,6 +141,21 @@ describe('Stripe checkout for an AWS Marketplace-billed reference', () => { ).resolves.toBe(true) }) + it('allows an organization whose only license is on a listing this deployment no longer sells', async () => { + const ownerId = await createUser() + const organizationId = await createOwnedOrganization(ownerId) + await insertLicense({ + organizationId, + linkedByUserId: ownerId, + status: 'active', + productCode: RETIRED_PRODUCT, + }) + + await expect( + authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN) + ).resolves.toBe(true) + }) + it('allows an organization with no license', async () => { const { ownerId, organizationId } = await organizationWithLicense(null) @@ -147,7 +168,7 @@ describe('Stripe checkout for an AWS Marketplace-billed reference', () => { const { ownerId, organizationId } = await organizationWithLicense('active') const userId = await createUser() await insertLicense({ organizationId: null, linkedByUserId: userId, status: 'active' }) - productCode.current = null + marketplace.enabled = false await expect( authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN) diff --git a/apps/sim/lib/billing/aws-marketplace/client.ts b/apps/sim/lib/billing/aws-marketplace/client.ts index 1e3ec4ee87d..0ff3a712e1a 100644 --- a/apps/sim/lib/billing/aws-marketplace/client.ts +++ b/apps/sim/lib/billing/aws-marketplace/client.ts @@ -29,10 +29,10 @@ let queueClient: SQSClient | null = null /** * Credentials for the Marketplace and event-queue clients only. On ECS the app's env also carries * a static IAM user's keys, which the SDK's default chain prefers and which Sim's general AWS - * clients (`getAwsCredentials()`) sign with. Infra grants ResolveCustomer, GetEntitlements, and the - * queue permissions to the ECS task role alone, and AWS asks for temporary role credentials here, - * so whenever the container credential endpoint is present these clients sign as the task role. - * Elsewhere (local development, tests) they keep the default chain. + * clients (`getAwsCredentials()`) sign with. Infra grants ResolveCustomer, GetEntitlements, + * BatchMeterUsage, and the queue permissions to the ECS task role alone, and AWS asks for + * temporary role credentials here, so whenever the container credential endpoint is present these + * clients sign as the task role. Elsewhere (local development, tests) they keep the default chain. */ function marketplaceClientConfig() { const relativeUri = env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI diff --git a/apps/sim/lib/billing/aws-marketplace/config.ts b/apps/sim/lib/billing/aws-marketplace/config.ts index 778a029e5b0..0239467d436 100644 --- a/apps/sim/lib/billing/aws-marketplace/config.ts +++ b/apps/sim/lib/billing/aws-marketplace/config.ts @@ -1,10 +1,31 @@ +import { + type AwsMarketplaceProduct, + type AwsMarketplaceProducts, + parseAwsMarketplaceProducts, +} from '@/lib/billing/aws-marketplace/products' import { env } from '@/lib/core/config/env' +let products: AwsMarketplaceProducts | undefined + /** - * The SaaS product code, or null when this deployment does not sell through AWS Marketplace: - * self-hosted installs and every environment but production. Null turns the registration, - * claim, and reconcile surfaces off. + * The products this deployment sells through AWS Marketplace, by product code. Empty on + * self-hosted installs and every environment but production, which turns the Marketplace surfaces + * off. The container entrypoint refuses to start on a malformed setting. */ -export function getAwsMarketplaceProductCode(): string | null { - return env.AWS_MARKETPLACE_PRODUCT_CODE ?? null +export function getAwsMarketplaceProducts(): AwsMarketplaceProducts { + products ??= env.AWS_MARKETPLACE_PRODUCTS + ? parseAwsMarketplaceProducts(env.AWS_MARKETPLACE_PRODUCTS) + : new Map() + return products +} + +export function isAwsMarketplaceEnabled(): boolean { + return getAwsMarketplaceProducts().size > 0 +} + +/** The configured product for a code AWS returned or a license row stored; null for any other. */ +export function findAwsMarketplaceProduct( + productCode: string | null | undefined +): AwsMarketplaceProduct | null { + return productCode ? (getAwsMarketplaceProducts().get(productCode) ?? null) : null } diff --git a/apps/sim/lib/billing/aws-marketplace/container-license.ts b/apps/sim/lib/billing/aws-marketplace/container-license.ts index 9701df401e5..fa42f646256 100644 --- a/apps/sim/lib/billing/aws-marketplace/container-license.ts +++ b/apps/sim/lib/billing/aws-marketplace/container-license.ts @@ -5,6 +5,7 @@ import { } from '@aws-sdk/client-license-manager' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { randomInt } from '@sim/utils/random' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' /** @@ -47,6 +48,19 @@ const ENTERPRISE_ENTITLEMENT = 'enterprise_license' /** AWS recommends re-checking every 15 minutes to pick up renewals, upgrades, and cancellations. */ const RECHECK_INTERVAL_MS = 15 * 60 * 1000 +/** + * Replicas start together, so their re-checks line up; each waits a random part of this before + * restarting, so a renewal or lapse never takes every replica down at once. + */ +const RESTART_SPREAD_MS = 10 * 60 * 1000 + +/** + * How the buyer's account holds the license. A delegate grant only shows licenses distributed to + * other accounts, and a grant still awaiting acceptance, or rejected, disabled, or deleted by the + * license administrator, entitles nothing. No status at all is a license the account bought itself. + */ +const USABLE_RECEIVED_STATUSES = new Set(['ACTIVE', 'WORKFLOW_COMPLETED']) + const ENTERPRISE_FLAGS = ['ENTERPRISE_ENABLED', 'NEXT_PUBLIC_ENTERPRISE_ENABLED'] as const /** `unknown` is a failed check (network, permissions, throttling), never a verdict on the license. */ @@ -64,14 +78,21 @@ function createClient(): LicenseManagerClient { }) } +function isUsableGrant({ ReceivedMetadata: received }: GrantedLicense): boolean { + if (received?.GrantType === 'DelegateGrant') return false + return !received?.ReceivedStatus || USABLE_RECEIVED_STATUSES.has(received.ReceivedStatus) +} + /** * An AWS Marketplace license this product's buyer holds is current when License Manager reports it - * `AVAILABLE`, its issuer is AWS Marketplace, and it carries the Enterprise entitlement. License - * Manager moves an ended, cancelled, or suspended license out of `AVAILABLE` itself. + * `AVAILABLE`, its issuer is AWS Marketplace, it carries the Enterprise entitlement, and the account + * holds it through a grant it may use. License Manager moves an ended, cancelled, or suspended + * license out of `AVAILABLE` itself. */ function isActiveEnterpriseLicense(license: GrantedLicense): boolean { return ( license.Status === 'AVAILABLE' && + isUsableGrant(license) && license.Issuer?.KeyFingerprint === MARKETPLACE_KEY_FINGERPRINT && (license.Entitlements ?? []).some((entitlement) => entitlement.Name === ENTERPRISE_ENTITLEMENT) ) @@ -133,9 +154,13 @@ export async function applyAwsMarketplaceContainerLicense(): Promise { const state = await checkLicense(client, productSku) const entitledNow = state === 'entitled' if (state === 'unknown' || entitledNow === enterpriseAtBoot) return - logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', { state }) clearInterval(recheck) - process.kill(process.pid, 'SIGTERM') + const delayMs = randomInt(0, RESTART_SPREAD_MS) + logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', { + state, + delayMs, + }) + setTimeout(() => process.kill(process.pid, 'SIGTERM'), delayMs) }, RECHECK_INTERVAL_MS) recheck.unref() } diff --git a/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts b/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts index 56088443a8c..eba529d05da 100644 --- a/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts +++ b/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts @@ -13,23 +13,42 @@ vi.mock('@aws-sdk/client-marketplace-entitlement-service', () => ({ })) import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' +import { + type AwsMarketplaceProduct, + parseAwsMarketplaceProducts, +} from '@/lib/billing/aws-marketplace/products' + +const PRODUCTS = parseAwsMarketplaceProducts( + 'product-placeholder=contract,usage-product-placeholder=platform-with-usage' +) + +function product(code: string): AwsMarketplaceProduct { + const found = PRODUCTS.get(code) + if (!found) throw new Error(`no test product ${code}`) + return found +} -const PRODUCT = 'product-placeholder' +const CONTRACT = product('product-placeholder') +const USAGE = product('usage-product-placeholder') const LICENSE = 'license-placeholder' const NOW = new Date('2026-06-01T00:00:00.000Z') function entitlement(overrides: Partial = {}): Entitlement { return { - ProductCode: PRODUCT, + ProductCode: CONTRACT.productCode, Dimension: 'enterprise', Value: { IntegerValue: 25 }, ...overrides, } } -function read(...entitlements: Entitlement[]) { +function readAs(product: AwsMarketplaceProduct, ...entitlements: Entitlement[]) { mockSend.mockResolvedValueOnce({ Entitlements: entitlements }) - return verifyLicense(PRODUCT, LICENSE) + return verifyLicense(product, LICENSE) +} + +function read(...entitlements: Entitlement[]) { + return readAs(CONTRACT, ...entitlements) } describe('verifyLicense', () => { @@ -47,7 +66,7 @@ describe('verifyLicense', () => { const summary = await read( entitlement({ ExpirationDate: new Date('2026-05-31T23:59:59.000Z') }) ) - expect(summary).toMatchObject({ active: false, entitlementCount: 1, quantity: 25 }) + expect(summary).toMatchObject({ active: false, accessReported: true, quantity: 25 }) }) it('reads an entitlement with a future expiry, or none at all, as active', async () => { @@ -85,6 +104,34 @@ describe('verifyLicense', () => { expect(summary).toMatchObject({ active: true, dimension: 'b-current', quantity: 50 }) }) + it('reads a usage entitlement alone as no answer about access, not as access', async () => { + const summary = await readAs(USAGE, entitlement({ Dimension: 'llm_credits' })) + expect(summary).toMatchObject({ + active: false, + licenseSeen: true, + accessReported: false, + dimension: null, + }) + }) + + it('reads a lapsed platform as inactive even while usage is current', async () => { + const summary = await readAs( + USAGE, + entitlement({ Dimension: 'llm_credits' }), + entitlement({ Dimension: 'platform', ExpirationDate: new Date('2026-01-01T00:00:00.000Z') }) + ) + expect(summary).toMatchObject({ active: false, accessReported: true, dimension: 'platform' }) + }) + + it('reads a current platform as active and describes it, not the usage dimension', async () => { + const summary = await readAs( + USAGE, + entitlement({ Dimension: 'llm_credits', Value: { IntegerValue: 7 } }), + entitlement({ Dimension: 'platform', Value: { IntegerValue: 1 } }) + ) + expect(summary).toMatchObject({ active: true, dimension: 'platform', quantity: 1 }) + }) + it('reads quantity only from an integer value, never from another value kind', async () => { expect((await read(entitlement({ Value: { DoubleValue: 1200.5 } }))).quantity).toBeNull() }) @@ -95,9 +142,9 @@ describe('verifyLicense', () => { .mockResolvedValueOnce({ Entitlements: [], NextToken: 't2' }) .mockResolvedValueOnce({ Entitlements: [entitlement({ Dimension: 'two' })] }) - const summary = await verifyLicense(PRODUCT, LICENSE) + const summary = await verifyLicense(CONTRACT, LICENSE) - expect(summary).toMatchObject({ entitlementCount: 2, dimension: 'one' }) + expect(summary).toMatchObject({ accessReported: true, dimension: 'one' }) expect(mockSend.mock.calls.map(([command]) => command.input.NextToken)).toEqual([ undefined, 't1', diff --git a/apps/sim/lib/billing/aws-marketplace/entitlements.ts b/apps/sim/lib/billing/aws-marketplace/entitlements.ts index 0b8ef4e454c..47c5dbb085b 100644 --- a/apps/sim/lib/billing/aws-marketplace/entitlements.ts +++ b/apps/sim/lib/billing/aws-marketplace/entitlements.ts @@ -4,16 +4,26 @@ import { } from '@aws-sdk/client-marketplace-entitlement-service' import { compareStrings } from '@sim/utils/string' import { getEntitlementClient } from '@/lib/billing/aws-marketplace/client' +import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products' /** A license carries one entitlement per purchased dimension; this bounds a runaway `NextToken`. */ const MAX_PAGES = 20 /** What the last Entitlement Service read says about one license. */ export interface EntitlementSummary { - /** At least one entitlement has no expiry or expires in the future. */ + /** + * The product's required dimension (any dimension, for a plan with none) has an entitlement with + * no expiry or one in the future. + */ active: boolean - /** Entitlements AWS returned, current or lapsed; zero is ambiguous, see the license's status. */ - entitlementCount: number + /** AWS returned an entitlement of any dimension: the license exists and names its account. */ + licenseSeen: boolean + /** + * AWS returned an entitlement, current or lapsed, for the dimension that grants access (any + * dimension, for a plan with none). A usage dimension alone is not an answer about access, and + * no answer is ambiguous: see the license's status. + */ + accessReported: boolean dimension: string | null quantity: number | null expiresAt: Date | null @@ -25,15 +35,20 @@ function isCurrent(entitlement: Entitlement, now: Date): boolean { } /** - * Reduces a license's entitlements to the one the row records. A current entitlement wins, the - * lowest dimension name first so repeated reads agree; with none current, the latest-expiring one - * still describes what lapsed. Quantity is read only from an integer value: a contract's dollar - * amount is never inferred from it. + * Reduces a license's entitlements to the one the row records. Only the plan's required dimension + * counts, when it names one; usage dimensions on the same license never grant access. A current + * entitlement wins, the lowest dimension name first so repeated reads agree; with none current, + * the latest-expiring one still describes what lapsed. Quantity is read only from an integer + * value: a contract's dollar amount is never inferred from it. */ function summarizeEntitlements( - entitlements: readonly Entitlement[], + allEntitlements: readonly Entitlement[], + requiredDimension: string | null, now: Date ): EntitlementSummary { + const entitlements = requiredDimension + ? allEntitlements.filter((entitlement) => entitlement.Dimension === requiredDimension) + : allEntitlements const current = entitlements .filter((entitlement) => isCurrent(entitlement, now)) .sort((left, right) => compareStrings(left.Dimension ?? '', right.Dimension ?? '')) @@ -45,11 +60,13 @@ function summarizeEntitlements( const chosen = current[0] ?? lapsed[0] return { active: current.length > 0, - entitlementCount: entitlements.length, + licenseSeen: allEntitlements.length > 0, + accessReported: entitlements.length > 0, dimension: chosen?.Dimension ?? null, quantity: chosen?.Value?.IntegerValue ?? null, expiresAt: chosen?.ExpirationDate ?? null, - customerAwsAccountId: chosen?.CustomerAWSAccountId ?? null, + customerAwsAccountId: + chosen?.CustomerAWSAccountId ?? allEntitlements[0]?.CustomerAWSAccountId ?? null, } } @@ -87,10 +104,10 @@ async function fetchLicenseEntitlements( /** Reads and summarizes one license's entitlements as of now; `signal` bounds the whole read. */ export async function verifyLicense( - productCode: string, + product: AwsMarketplaceProduct, licenseArn: string, signal?: AbortSignal ): Promise { - const entitlements = await fetchLicenseEntitlements(productCode, licenseArn, signal) - return summarizeEntitlements(entitlements, new Date()) + const entitlements = await fetchLicenseEntitlements(product.productCode, licenseArn, signal) + return summarizeEntitlements(entitlements, product.plan.requiredDimension, new Date()) } diff --git a/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts index db0d1d85e7c..a10924df7dd 100644 --- a/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts +++ b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts @@ -6,6 +6,7 @@ import { member, organization, outboxEvent, + subscription, user, } from '@sim/db/schema' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' @@ -17,13 +18,16 @@ import { NextRequest } from 'next/server' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const PRODUCT = 'product-placeholder' +/** A second listing, sold as platform access plus metered usage. */ +const USAGE_PRODUCT = 'usage-product-placeholder' /** New SaaS products: ResolveCustomer names the buyer by account ID, never `CustomerIdentifier`. */ const ACCOUNT = '000000000000' const AGREEMENT = 'agmt-placeholder' const CONTACT = { contactEmail: 'buyer@example.test', companyName: 'Example Co' } const { meteringSend, entitlementSend, queueSend } = vi.hoisted(() => { - process.env.AWS_MARKETPLACE_PRODUCT_CODE = 'product-placeholder' + process.env.AWS_MARKETPLACE_PRODUCTS = + 'product-placeholder=contract,usage-product-placeholder=platform-with-usage' process.env.AWS_MARKETPLACE_EVENTS_QUEUE_URL = 'https://queue.example.test/aws-marketplace-placeholder' process.env.ADMIN_API_KEY = 'admin-key-placeholder' @@ -300,13 +304,18 @@ function deliverOnce(events: Array>) { return deleted } -function licenseUpdated(licenseArn: string, agreementId?: string, time?: Date) { +function licenseUpdated( + licenseArn: string, + agreementId?: string, + time?: Date, + productCode = PRODUCT +) { return { 'detail-type': 'License Updated - Manufacturer', ...(time && { time: time.toISOString() }), detail: { license: { arn: licenseArn }, - product: { code: PRODUCT }, + product: { code: productCode }, ...(agreementId && { agreement: { id: agreementId } }), }, } @@ -344,6 +353,7 @@ afterAll(async () => { .delete(awsMarketplaceLicense) .where(inArray(awsMarketplaceLicense.licenseArn, licenseArns)) if (organizationIds.length) { + await db.delete(subscription).where(inArray(subscription.referenceId, organizationIds)) await db.delete(organization).where(inArray(organization.id, organizationIds)) } if (userIds.length) { @@ -390,6 +400,39 @@ describe('POST /api/marketplace/aws/register', () => { expect(meteringSend.mock.calls[0][0].input.RegistrationToken).toBe('opaque+token') }) + it('admits a buyer of the usage listing only on a current platform entitlement', async () => { + const usageOnly = uniqueArn() + resolvesTo(usageOnly, USAGE_PRODUCT) + entitlementsByLicense.set(usageOnly, [ + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }), + ]) + + const unconfirmed = await registration('x-amzn-marketplace-token=usage-only-token') + + expect(claimTokenOf(unconfirmed)).not.toBeNull() + expect(entitlementSend).toHaveBeenCalledTimes(3) + expect(await readLicense(usageOnly)).toMatchObject({ + productCode: USAGE_PRODUCT, + status: 'pending', + }) + + const withPlatform = uniqueArn() + resolvesTo(withPlatform, USAGE_PRODUCT) + entitlementsByLicense.set(withPlatform, [ + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }), + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'platform' }), + ]) + + const admitted = await registration('x-amzn-marketplace-token=platform-token') + + expect(claimTokenOf(admitted)).not.toBeNull() + expect(await readLicense(withPlatform)).toMatchObject({ + productCode: USAGE_PRODUCT, + status: 'active', + dimension: 'platform', + }) + }) + it('records a lapsed license as inactive and issues no claim', async () => { const licenseArn = uniqueArn() resolvesTo(licenseArn) @@ -702,6 +745,28 @@ describe('linking a claimed license', () => { }) }) + it('starts the license usage at the first link and keeps it when the same organization links again', async () => { + const fresh = await createClaimedLicense() + entitlementsByLicense.set(fresh.licenseArn, [currentEntitlement()]) + const before = Date.now() + + await link(ownerId, { licenseId: fresh.id, claimToken: fresh.token }) + + const linkedAt = (await readLicense(fresh.licenseArn))?.organizationLinkedAt + expect(linkedAt?.getTime()).toBeGreaterThanOrEqual(before - 1000) + + const firstLinkedAt = new Date(Date.now() - 5 * 60 * 60 * 1000) + const relinked = await createClaimedLicense({ + organizationId, + organizationLinkedAt: firstLinkedAt, + }) + entitlementsByLicense.set(relinked.licenseArn, [currentEntitlement()]) + + await link(ownerId, { licenseId: relinked.id, claimToken: relinked.token }) + + expect((await readLicense(relinked.licenseArn))?.organizationLinkedAt).toEqual(firstLinkedAt) + }) + it('refuses to move a license already linked to another organization', async () => { const otherOwnerId = await createUser() const otherOrganizationId = await createOrganization([{ userId: otherOwnerId, role: 'owner' }]) @@ -716,6 +781,72 @@ describe('linking a claimed license', () => { expect((await readLicense(license.licenseArn))?.organizationId).toBe(otherOrganizationId) }) + it('refuses an organization with a live Stripe self-serve subscription, but not Enterprise or an abandoned checkout', async () => { + async function organizationWith(sub: { plan: string; status: string; bound: boolean }) { + const planOwnerId = await createUser() + const id = await createOrganization([{ userId: planOwnerId, role: 'owner' }]) + await db.insert(subscription).values({ + id: generateId(), + plan: sub.plan, + referenceId: id, + status: sub.status, + stripeSubscriptionId: sub.bound ? `sub_${generateId()}` : null, + metadata: { plan: sub.plan, referenceId: id, seats: 10, monthlyPrice: 100 }, + }) + return { id, planOwnerId } + } + async function linkTo(organization: { id: string; planOwnerId: string }) { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + const refusal = await linkAwsMarketplaceLicense + .execute({ + principal: createSessionPrincipal({ userId: organization.planOwnerId }), + input: { + ...CONTACT, + organizationId: organization.id, + licenseId: license.id, + claimToken: license.token, + }, + }) + .then( + () => null, + (error: unknown) => error + ) + return { license, refusal } + } + + const trial = await linkTo( + await organizationWith({ plan: 'team_6000', status: 'trialing', bound: true }) + ) + const onEnterprise = await organizationWith({ + plan: 'enterprise', + status: 'active', + bound: true, + }) + const enterprise = await linkTo(onEnterprise) + const abandoned = await organizationWith({ + plan: 'team_6000', + status: 'incomplete', + bound: false, + }) + const afterAbandoned = await linkTo(abandoned) + + expect(trial.refusal).toMatchObject({ + code: 'conflict', + message: expect.stringMatching(/already pays/), + }) + expect(await readLicense(trial.license.licenseArn)).toMatchObject({ + organizationId: null, + claimTokenHash: hashClaimToken(trial.license.token), + }) + expect(enterprise.refusal).toBeNull() + expect(afterAbandoned.refusal).toBeNull() + expect((await readLicense(enterprise.license.licenseArn))?.organizationId).toBe(onEnterprise.id) + expect((await readLicense(afterAbandoned.license.licenseArn))?.organizationId).toBe( + abandoned.id + ) + }) + it('links under a newer active read without overwriting it with its own older one', async () => { const license = await createClaimedLicense({ lastVerifiedAt: new Date(Date.now() + 60_000) }) entitlementsByLicense.set(license.licenseArn, [ @@ -1382,6 +1513,57 @@ describe('reconcileAwsMarketplaceLicenses', () => { }) }) +describe('license events across listings', () => { + it('records a usage listing license seen first by event as pending until AWS reports platform', async () => { + const licenseArn = uniqueArn() + entitlementsByLicense.set(licenseArn, [ + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }), + ]) + const deleted = deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 1, discarded: 0 }) + expect(deleted).toEqual(['r-0']) + expect(await readLicense(licenseArn)).toMatchObject({ + productCode: USAGE_PRODUCT, + status: 'pending', + dimension: null, + }) + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/Unconfirmed/) }), + ]) + }) + + it("applies a usage listing's event under its own plan: a lapsed platform ends access", async () => { + const { licenseArn } = await insertLicense({ productCode: USAGE_PRODUCT, status: 'active' }) + entitlementsByLicense.set(licenseArn, [ + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }), + currentEntitlement({ + ProductCode: USAGE_PRODUCT, + Dimension: 'platform', + ExpirationDate: new Date(Date.now() - 60_000), + }), + ]) + const deleted = deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 1, discarded: 0 }) + expect(deleted).toEqual(['r-0']) + const lapsed = await readLicense(licenseArn) + expect(lapsed).toMatchObject({ status: 'inactive', dimension: 'platform' }) + expect(lapsed?.endedAt).toBeInstanceOf(Date) + + deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)]) + await reconcileAwsMarketplaceLicenses() + expect((await readLicense(licenseArn))?.endedAt).toEqual(lapsed?.endedAt) + + entitlementsByLicense.set(licenseArn, [ + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'platform' }), + ]) + deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)]) + await reconcileAwsMarketplaceLicenses() + expect(await readLicense(licenseArn)).toMatchObject({ status: 'active', endedAt: null }) + }) +}) + describe('agreement events', () => { it('re-verifies the license of an ended agreement, from an event that names no product', async () => { const agreementId = uniqueAgreement() diff --git a/apps/sim/lib/billing/aws-marketplace/metering.integration.ts b/apps/sim/lib/billing/aws-marketplace/metering.integration.ts new file mode 100644 index 00000000000..30d7ecf63d4 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/metering.integration.ts @@ -0,0 +1,554 @@ +import { db } from '@sim/db' +import { + awsMarketplaceLicense, + awsMarketplaceUsageReport, + awsMarketplaceUsageTally, + organization, + usageLog, + user, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { asc, eq, inArray } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const CONTRACT_PRODUCT = 'product-placeholder' +const USAGE_PRODUCT = 'usage-product-placeholder' +const ACCOUNT = '000000000000' +const HOUR_MS = 60 * 60 * 1000 +const DAY_MS = 24 * HOUR_MS + +interface SentRecord { + LicenseArn?: string + CustomerAWSAccountId?: string + Dimension?: string + Quantity?: number + Timestamp?: Date +} + +const { meteringSend } = vi.hoisted(() => { + process.env.AWS_MARKETPLACE_PRODUCTS = + 'product-placeholder=contract,usage-product-placeholder=platform-with-usage' + return { meteringSend: vi.fn() } +}) + +vi.mock('@aws-sdk/client-marketplace-metering', () => ({ + MarketplaceMeteringClient: class { + send = meteringSend + }, + BatchMeterUsageCommand: class { + constructor(public input: { UsageRecords: SentRecord[]; ProductCode?: string }) {} + }, +})) + +import { meterAwsMarketplaceUsage } from '@/lib/billing/aws-marketplace/metering' + +/** Twenty minutes into an hour. */ +const NOW = Math.floor(Date.now() / HOUR_MS) * HOUR_MS + 20 * 60 * 1000 +const floorHour = (time: number) => new Date(Math.floor(time / HOUR_MS) * HOUR_MS) +const HOUR = floorHour(NOW) +const NEXT_HOUR = NOW + HOUR_MS +const minutesAgo = (minutes: number) => new Date(NOW - minutes * 60_000) +/** The billing period every fixture's usage falls in, unless a test says otherwise. */ +const PERIOD = { start: new Date(NOW - 10 * DAY_MS), end: new Date(NOW + 20 * DAY_MS) } + +const userIds: string[] = [] +const organizationIds: string[] = [] +const licenseIds: string[] = [] + +/** Every record sent for the license, with the request's top-level product code alongside. */ +function sentRecords(licenseArn: string): Array { + return meteringSend.mock.calls.flatMap(([command]) => + command.input.UsageRecords.filter((record: SentRecord) => record.LicenseArn === licenseArn).map( + (record: SentRecord) => ({ ...record, productCode: command.input.ProductCode }) + ) + ) +} + +/** AWS accepts every record, echoing it back with a metering record ID. */ +function acceptEverything() { + meteringSend.mockImplementation(async (command: { input: { UsageRecords: SentRecord[] } }) => ({ + Results: command.input.UsageRecords.map((record, index) => ({ + UsageRecord: record, + MeteringRecordId: `record-${index}`, + Status: 'Success', + })), + UnprocessedRecords: [], + })) +} + +async function createOrganization(): Promise<{ organizationId: string; userId: string }> { + const userId = generateId() + userIds.push(userId) + await db.insert(user).values({ + id: userId, + name: 'Metering fixture', + email: `${userId}@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + const organizationId = generateId() + organizationIds.push(organizationId) + await db.insert(organization).values({ + id: organizationId, + name: 'Metered org', + slug: organizationId, + createdAt: new Date(), + }) + return { organizationId, userId } +} + +async function insertLicense( + organizationId: string, + overrides: Partial = {} +) { + const id = generateId() + licenseIds.push(id) + const licenseArn = `arn:aws:license-manager::000000000000:license:l-${id}` + await db.insert(awsMarketplaceLicense).values({ + id, + licenseArn, + productCode: USAGE_PRODUCT, + customerAwsAccountId: ACCOUNT, + status: 'active', + dimension: 'platform', + organizationId, + organizationLinkedAt: new Date(NOW - 5 * HOUR_MS), + lastVerifiedAt: new Date(), + lastVerificationAttemptAt: new Date(), + ...overrides, + }) + return { id, licenseArn } +} + +async function logUsage( + owner: { organizationId: string; userId: string }, + input: { + at: Date + cost: number + category?: 'model' | 'model_unbilled' | 'tool' + period?: { start: Date; end: Date } + } +): Promise { + const id = generateId() + const period = input.period ?? PERIOD + await db.insert(usageLog).values({ + id, + userId: owner.userId, + category: input.category ?? 'model', + source: 'workflow', + description: 'metering fixture', + cost: input.cost.toString(), + billingEntityType: 'organization', + billingEntityId: owner.organizationId, + billingPeriodStart: period.start, + billingPeriodEnd: period.end, + createdAt: input.at, + }) + return id +} + +async function reportsFor(organizationId: string) { + return db + .select() + .from(awsMarketplaceUsageReport) + .where(eq(awsMarketplaceUsageReport.organizationId, organizationId)) + .orderBy(asc(awsMarketplaceUsageReport.hourStart)) +} + +/** Charges the usage now, then sends it once its hour has closed. */ +async function chargeAndSend() { + await meterAwsMarketplaceUsage(NOW) + await meterAwsMarketplaceUsage(NEXT_HOUR) +} + +beforeEach(() => { + meteringSend.mockReset() + acceptEverything() +}) + +afterEach(async () => { + if (licenseIds.length) { + await db + .delete(awsMarketplaceLicense) + .where(inArray(awsMarketplaceLicense.id, licenseIds.splice(0))) + } + if (organizationIds.length) { + await db.delete(organization).where(inArray(organization.id, organizationIds.splice(0))) + } + if (userIds.length) await db.delete(user).where(inArray(user.id, userIds.splice(0))) +}) + +describe('AWS Marketplace usage metering', () => { + it('charges whole credits during the hour, carrying fractions, and sends once the hour closes', async () => { + const owner = await createOrganization() + const { licenseArn } = await insertLicense(owner.organizationId) + await logUsage(owner, { at: minutesAgo(30), cost: 0.015 }) + await logUsage(owner, { at: minutesAgo(10), cost: 0.01 }) + + await meterAwsMarketplaceUsage(NOW) + await logUsage(owner, { at: minutesAgo(1), cost: 0.006 }) + await meterAwsMarketplaceUsage(NOW + 5 * 60_000) + + expect(sentRecords(licenseArn)).toEqual([]) + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ hourStart: HOUR, quantity: 3, status: 'pending' }), + ]) + + await meterAwsMarketplaceUsage(NEXT_HOUR) + + expect(sentRecords(licenseArn)).toEqual([ + { + LicenseArn: licenseArn, + CustomerAWSAccountId: ACCOUNT, + Dimension: 'llm_credits', + Quantity: 3, + Timestamp: HOUR, + productCode: undefined, + }, + ]) + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ status: 'reported', meteringRecordId: 'record-0' }), + ]) + }) + + it('charges cost raised in place after it was first counted', async () => { + const owner = await createOrganization() + await insertLicense(owner.organizationId) + const usageId = await logUsage(owner, { at: minutesAgo(30), cost: 0.02 }) + await chargeAndSend() + + await db.update(usageLog).set({ cost: '0.05' }).where(eq(usageLog.id, usageId)) + await meterAwsMarketplaceUsage(NEXT_HOUR + 5 * 60_000) + + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ hourStart: HOUR, quantity: 2, status: 'reported' }), + expect.objectContaining({ hourStart: floorHour(NEXT_HOUR), quantity: 3, status: 'pending' }), + ]) + }) + + it('never charges bring-your-own-key or tool usage, or usage from before the link', async () => { + const owner = await createOrganization() + await insertLicense(owner.organizationId, { organizationLinkedAt: minutesAgo(30) }) + await logUsage(owner, { at: minutesAgo(40), cost: 5 }) + await logUsage(owner, { at: minutesAgo(20), cost: 7, category: 'model_unbilled' }) + await logUsage(owner, { at: minutesAgo(15), cost: 9, category: 'tool' }) + await logUsage(owner, { at: minutesAgo(10), cost: 0.03 }) + + await meterAwsMarketplaceUsage(NOW) + + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ quantity: 3 }), + ]) + }) + + it('charges usage once however often it runs', async () => { + const owner = await createOrganization() + await insertLicense(owner.organizationId) + await logUsage(owner, { at: minutesAgo(10), cost: 0.5 }) + + await meterAwsMarketplaceUsage(NOW) + await meterAwsMarketplaceUsage(NOW + 60_000) + await meterAwsMarketplaceUsage(NOW + 2 * 60_000) + + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ quantity: 50 }), + ]) + }) + + it('charges the earliest-linked license in force, skipping one past its expiry', async () => { + const owner = await createOrganization() + const expired = await insertLicense(owner.organizationId, { + organizationLinkedAt: new Date(NOW - 9 * HOUR_MS), + expiresAt: minutesAgo(5), + }) + const earlier = await insertLicense(owner.organizationId, { + organizationLinkedAt: new Date(NOW - 8 * HOUR_MS), + }) + const later = await insertLicense(owner.organizationId, { + organizationLinkedAt: new Date(NOW - 7 * HOUR_MS), + }) + await logUsage(owner, { at: minutesAgo(10), cost: 0.07 }) + + await chargeAndSend() + + expect(sentRecords(earlier.licenseArn)).toEqual([expect.objectContaining({ Quantity: 7 })]) + expect(sentRecords(expired.licenseArn)).toEqual([]) + expect(sentRecords(later.licenseArn)).toEqual([]) + }) + + it('meters nothing for a contract-only listing', async () => { + const owner = await createOrganization() + await insertLicense(owner.organizationId, { productCode: CONTRACT_PRODUCT }) + await logUsage(owner, { at: minutesAgo(10), cost: 4 }) + + await chargeAndSend() + + expect(await reportsFor(owner.organizationId)).toEqual([]) + }) + + it('flushes an ended license once, at the hour it ended, even with no expiry from AWS', async () => { + const owner = await createOrganization() + const endedAt = minutesAgo(10) + const { licenseArn } = await insertLicense(owner.organizationId, { + status: 'inactive', + expiresAt: null, + endedAt, + }) + await logUsage(owner, { at: minutesAgo(15), cost: 0.02 }) + + await meterAwsMarketplaceUsage(NOW) + + expect(sentRecords(licenseArn)).toEqual([ + expect.objectContaining({ Quantity: 2, Timestamp: floorHour(endedAt.getTime()) }), + ]) + + await logUsage(owner, { at: minutesAgo(1), cost: 3 }) + await meterAwsMarketplaceUsage(NOW + 5 * 60_000) + + expect(sentRecords(licenseArn)).toHaveLength(1) + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ quantity: 2, status: 'reported' }), + ]) + }) + + it('starts billing a linked license with no link time from the first run that sees it', async () => { + const owner = await createOrganization() + const { id } = await insertLicense(owner.organizationId, { organizationLinkedAt: null }) + await logUsage(owner, { at: minutesAgo(10), cost: 5 }) + + await meterAwsMarketplaceUsage(NOW) + const [stamped] = await db + .select({ at: awsMarketplaceLicense.organizationLinkedAt }) + .from(awsMarketplaceLicense) + .where(eq(awsMarketplaceLicense.id, id)) + await logUsage(owner, { at: new Date(NOW + 1000), cost: 0.04 }) + await meterAwsMarketplaceUsage(NOW + 60_000) + + expect(stamped?.at).toEqual(new Date(NOW)) + expect((await reportsFor(owner.organizationId)).map((row) => row.quantity)).toEqual([4]) + }) + + it('keeps counting a period while a long run can still raise its rows, and leaves a settled one alone', async () => { + const owner = await createOrganization() + await insertLicense(owner.organizationId, { organizationLinkedAt: new Date(NOW - 70 * DAY_MS) }) + const justEnded = { start: new Date(NOW - 31 * DAY_MS), end: new Date(NOW - 3 * DAY_MS) } + const longEnded = { start: new Date(NOW - 62 * DAY_MS), end: new Date(NOW - 31 * DAY_MS) } + await logUsage(owner, { at: new Date(NOW - 4 * DAY_MS), cost: 0.04, period: justEnded }) + await logUsage(owner, { at: new Date(NOW - 35 * DAY_MS), cost: 9, period: longEnded }) + + await meterAwsMarketplaceUsage(NOW) + + expect((await reportsFor(owner.organizationId)).map((row) => row.quantity)).toEqual([4]) + const tallies = await db + .select({ periodStart: awsMarketplaceUsageTally.periodStart }) + .from(awsMarketplaceUsageTally) + .where(eq(awsMarketplaceUsageTally.organizationId, owner.organizationId)) + expect(tallies).toEqual([{ periodStart: justEnded.start }]) + }) + + it('keeps unprocessed records for the next run and rejects records for an unsubscribed customer', async () => { + const retried = await createOrganization() + const refused = await createOrganization() + const retriedLicense = await insertLicense(retried.organizationId) + const refusedLicense = await insertLicense(refused.organizationId) + await logUsage(retried, { at: minutesAgo(10), cost: 0.01 }) + await logUsage(refused, { at: minutesAgo(10), cost: 0.01 }) + await meterAwsMarketplaceUsage(NOW) + meteringSend.mockImplementation(async (command: { input: { UsageRecords: SentRecord[] } }) => ({ + Results: command.input.UsageRecords.filter( + (record) => record.LicenseArn === refusedLicense.licenseArn + ).map((record) => ({ UsageRecord: record, Status: 'CustomerNotSubscribed' })), + UnprocessedRecords: command.input.UsageRecords.filter( + (record) => record.LicenseArn === retriedLicense.licenseArn + ), + })) + + await meterAwsMarketplaceUsage(NEXT_HOUR) + + expect(await reportsFor(retried.organizationId)).toEqual([ + expect.objectContaining({ status: 'pending' }), + ]) + expect(await reportsFor(refused.organizationId)).toEqual([ + expect.objectContaining({ status: 'rejected', error: 'CustomerNotSubscribed' }), + ]) + + acceptEverything() + await meterAwsMarketplaceUsage(NEXT_HOUR + 5 * 60_000) + + expect(await reportsFor(retried.organizationId)).toEqual([ + expect.objectContaining({ status: 'reported' }), + ]) + expect(sentRecords(retriedLicense.licenseArn)).toHaveLength(2) + expect(sentRecords(refusedLicense.licenseArn)).toHaveLength(1) + }) + + it('rejects a license AWS refuses outright and retries one a transient failure stopped', async () => { + const refused = await createOrganization() + const throttled = await createOrganization() + const refusedLicense = await insertLicense(refused.organizationId) + const throttledLicense = await insertLicense(throttled.organizationId) + await logUsage(refused, { at: minutesAgo(10), cost: 0.01 }) + await logUsage(throttled, { at: minutesAgo(10), cost: 0.01 }) + await meterAwsMarketplaceUsage(NOW) + meteringSend.mockImplementation(async (command: { input: { UsageRecords: SentRecord[] } }) => { + const arn = command.input.UsageRecords[0]?.LicenseArn + if (arn !== refusedLicense.licenseArn && arn !== throttledLicense.licenseArn) { + return { Results: [], UnprocessedRecords: [] } + } + const error = new Error('refused') + error.name = + arn === refusedLicense.licenseArn ? 'InvalidLicenseException' : 'ThrottlingException' + throw error + }) + + await meterAwsMarketplaceUsage(NEXT_HOUR) + + expect(await reportsFor(refused.organizationId)).toEqual([ + expect.objectContaining({ status: 'rejected', error: 'InvalidLicenseException' }), + ]) + expect(await reportsFor(throttled.organizationId)).toEqual([ + expect.objectContaining({ status: 'pending' }), + ]) + }) + + it('rejects a pending record once it is older than AWS accepts', async () => { + const owner = await createOrganization() + const { licenseArn } = await insertLicense(owner.organizationId) + await logUsage(owner, { at: minutesAgo(10), cost: 0.01 }) + await meterAwsMarketplaceUsage(NOW) + meteringSend.mockResolvedValue({ Results: [], UnprocessedRecords: [] }) + await meterAwsMarketplaceUsage(NEXT_HOUR) + meteringSend.mockClear() + + await meterAwsMarketplaceUsage(NOW + 25 * HOUR_MS) + + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ status: 'rejected', error: expect.stringMatching(/window/) }), + ]) + expect(sentRecords(licenseArn)).toEqual([]) + }) + + it('resends a record at the quantity first sent, keeping later usage off it', async () => { + const owner = await createOrganization() + const { licenseArn } = await insertLicense(owner.organizationId, { + status: 'inactive', + expiresAt: null, + endedAt: minutesAgo(10), + }) + await logUsage(owner, { at: minutesAgo(15), cost: 0.02 }) + meteringSend.mockRejectedValueOnce( + Object.assign(new Error('timed out'), { name: 'TimeoutError' }) + ) + + await meterAwsMarketplaceUsage(NOW) + await logUsage(owner, { at: minutesAgo(1), cost: 3 }) + await meterAwsMarketplaceUsage(NOW + 5 * 60_000) + + expect(sentRecords(licenseArn).map((record) => record.Quantity)).toEqual([2, 2]) + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ quantity: 2, status: 'reported' }), + ]) + }) + + it('rejects only the record whose timestamp AWS finds out of range', async () => { + const owner = await createOrganization() + const { licenseArn } = await insertLicense(owner.organizationId) + const earlierHour = floorHour(NOW - 2 * HOUR_MS) + await logUsage(owner, { at: minutesAgo(130), cost: 0.01 }) + await meterAwsMarketplaceUsage(NOW - 2 * HOUR_MS) + await logUsage(owner, { at: minutesAgo(10), cost: 0.02 }) + meteringSend.mockResolvedValue({ Results: [], UnprocessedRecords: [] }) + await meterAwsMarketplaceUsage(NOW) + acceptEverything() + const accept = meteringSend.getMockImplementation() + meteringSend.mockImplementation(async (command: { input: { UsageRecords: SentRecord[] } }) => { + if ( + command.input.UsageRecords.some( + (record) => record.Timestamp?.getTime() === earlierHour.getTime() + ) + ) { + throw Object.assign(new Error('out of range'), { name: 'TimestampOutOfBoundsException' }) + } + return accept?.(command) + }) + + await meterAwsMarketplaceUsage(NEXT_HOUR) + + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ + hourStart: earlierHour, + status: 'rejected', + error: 'TimestampOutOfBoundsException', + }), + expect.objectContaining({ hourStart: HOUR, quantity: 2, status: 'reported' }), + ]) + expect(sentRecords(licenseArn).at(-1)).toEqual(expect.objectContaining({ Timestamp: HOUR })) + }) + + it("sends last month's records until the month-end grace passes, then rejects them", async () => { + const owner = await createOrganization() + const today = new Date(NOW) + const monthStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 1) + const period = { + start: new Date(monthStart - 10 * DAY_MS), + end: new Date(monthStart + 20 * DAY_MS), + } + const { licenseArn } = await insertLicense(owner.organizationId, { + organizationLinkedAt: new Date(monthStart - 5 * HOUR_MS), + }) + const lastHourOfMonth = new Date(monthStart - HOUR_MS) + await logUsage(owner, { at: new Date(monthStart - 50 * 60_000), cost: 0.01, period }) + await meterAwsMarketplaceUsage(monthStart - 30 * 60_000) + meteringSend.mockResolvedValue({ Results: [], UnprocessedRecords: [] }) + + await meterAwsMarketplaceUsage(monthStart + 5 * HOUR_MS) + + expect(sentRecords(licenseArn)).toEqual([ + expect.objectContaining({ Timestamp: lastHourOfMonth }), + ]) + + meteringSend.mockClear() + await meterAwsMarketplaceUsage(monthStart + 7 * HOUR_MS) + + expect(sentRecords(licenseArn)).toEqual([]) + expect(await reportsFor(owner.organizationId)).toEqual([ + expect.objectContaining({ + hourStart: lastHourOfMonth, + status: 'rejected', + error: expect.stringMatching(/window/), + }), + ]) + }) + + it('flushes a license at its expiry before any check marks it inactive', async () => { + const owner = await createOrganization() + const expiresAt = minutesAgo(30) + const { licenseArn } = await insertLicense(owner.organizationId, { expiresAt }) + await logUsage(owner, { at: minutesAgo(35), cost: 0.03 }) + + await meterAwsMarketplaceUsage(NOW) + + expect(sentRecords(licenseArn)).toEqual([ + expect.objectContaining({ Quantity: 3, Timestamp: floorHour(expiresAt.getTime()) }), + ]) + }) + + it('still sends usage already charged after its organization is deleted', async () => { + const owner = await createOrganization() + const { id, licenseArn } = await insertLicense(owner.organizationId) + await logUsage(owner, { at: minutesAgo(10), cost: 0.02 }) + await meterAwsMarketplaceUsage(NOW) + + await db.delete(organization).where(eq(organization.id, owner.organizationId)) + await meterAwsMarketplaceUsage(NEXT_HOUR) + + expect(sentRecords(licenseArn)).toEqual([expect.objectContaining({ Quantity: 2 })]) + expect( + await db + .select({ status: awsMarketplaceUsageReport.status }) + .from(awsMarketplaceUsageReport) + .where(eq(awsMarketplaceUsageReport.licenseId, id)) + ).toEqual([{ status: 'reported' }]) + }) +}) diff --git a/apps/sim/lib/billing/aws-marketplace/metering.ts b/apps/sim/lib/billing/aws-marketplace/metering.ts new file mode 100644 index 00000000000..4c5468f4af5 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/metering.ts @@ -0,0 +1,588 @@ +/** + * Bills AWS Marketplace usage. Usage cost is recorded in place and can grow after it is first + * written, so each run charges what changed in the books rather than what an hour of rows says: + * it re-sums every billing period of an organization's billed usage that can still change, + * charges the whole units above what that period's tally already counted, and adds them to the + * current hour's record for the license being billed. A record is sent once its hour closes, or at + * once for a license that has ended, and AWS keeps the first record it accepts for a license, + * dimension and hour, so a record's quantity is fixed once it is first sent. + */ + +import { BatchMeterUsageCommand } from '@aws-sdk/client-marketplace-metering' +import { db } from '@sim/db' +import { + awsMarketplaceLicense, + awsMarketplaceUsageReport, + awsMarketplaceUsageTally, + usageLog, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { chunkArray } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { compareStrings } from '@sim/utils/string' +import { + and, + asc, + eq, + gt, + gte, + inArray, + isNotNull, + isNull, + lt, + lte, + or, + type SQL, + sql, +} from 'drizzle-orm' +import { getMeteringClient } from '@/lib/billing/aws-marketplace/client' +import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config' +import type { AwsMarketplaceUsagePricing } from '@/lib/billing/aws-marketplace/products' +import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults' +import { toDecimal, toFixedString } from '@/lib/billing/utils/decimal' +import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' +import type { DbTransaction } from '@/lib/db/types' + +const logger = createLogger('AwsMarketplaceMetering') + +const HOUR_MS = 60 * 60 * 1000 +/** AWS refuses a record 24 hours or more after its timestamp. */ +const AWS_ACCEPTS_MS = 24 * HOUR_MS +/** AWS takes the previous month's records until 06:00 UTC on the first day of the next month. */ +const MONTH_GRACE_MS = 6 * HOUR_MS +/** AWS takes a license's last usage for about an hour after it ends. */ +const AFTER_END_MS = HOUR_MS +/** + * How long a usage row's cost can keep growing: a request tops up its row in place while it runs, + * and no run outlasts the longest execution timeout. A day of margin covers late cost callbacks. + */ +const ROW_SETTLE_MS = MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS * 1000 + 24 * HOUR_MS +/** BatchMeterUsage takes at most 25 records. */ +const RECORDS_PER_CALL = 25 +/** Rows sent per run, oldest first; any beyond it go in the next run. */ +const MAX_ROWS_PER_RUN = 500 + +/** + * AWS refuses these for everything the license sends, so retrying cannot help: the records are + * rejected and the error is logged for the team. + */ +const LICENSE_LEVEL_ERRORS = new Set([ + 'InvalidLicenseException', + 'InvalidCustomerIdentifierException', + 'InvalidProductCodeException', + 'InvalidUsageDimensionException', +]) +/** AWS refuses a whole call when any one record's timestamp is out of range. */ +const TIMESTAMP_OUT_OF_BOUNDS = 'TimestampOutOfBoundsException' + +export interface AwsMarketplaceMeteringSummary { + /** Units newly charged to a license this run. */ + charged: number + reported: number + rejected: number + /** Rows left pending for the next run: unprocessed by AWS, or a transient failure. */ + retained: number +} + +interface MeteredLicense { + id: string + organizationId: string + organizationLinkedAt: Date + status: 'pending' | 'active' | 'inactive' + expiresAt: Date | null + endedAt: Date | null + usage: AwsMarketplaceUsagePricing +} + +/** A ledger row awaiting AWS, with the license it bills. */ +interface PendingReport { + id: string + organizationId: string | null + dimension: string + hourStart: Date + quantity: number + licenseArn: string + customerAwsAccountId: string +} + +function floorHour(time: number): number { + return Math.floor(time / HOUR_MS) * HOUR_MS +} + +/** Serializes charging and sending for one organization and dimension. */ +function lockUsage(tx: DbTransaction, organizationId: string, dimension: string): Promise { + return acquireAdvisoryXactLock( + tx, + 'aws_marketplace_usage', + `aws-marketplace-usage:${organizationId}:${dimension}` + ) +} + +/** + * Starts billing for linked licenses that have no link time, such as those linked before it was + * recorded: their usage counts from the first run that sees them. + */ +async function stampUnmarkedLinks(productCodes: string[], now: number): Promise { + await db + .update(awsMarketplaceLicense) + .set({ organizationLinkedAt: new Date(now) }) + .where( + and( + inArray(awsMarketplaceLicense.productCode, productCodes), + isNotNull(awsMarketplaceLicense.organizationId), + isNull(awsMarketplaceLicense.organizationLinkedAt) + ) + ) +} + +/** Linked licenses on a plan that bills usage, in force or ended recently enough to still bill. */ +async function listMeteredLicenses( + usageByProduct: ReadonlyMap, + now: number +): Promise { + const rows = await db + .select({ + id: awsMarketplaceLicense.id, + productCode: awsMarketplaceLicense.productCode, + organizationId: awsMarketplaceLicense.organizationId, + organizationLinkedAt: awsMarketplaceLicense.organizationLinkedAt, + status: awsMarketplaceLicense.status, + expiresAt: awsMarketplaceLicense.expiresAt, + endedAt: awsMarketplaceLicense.endedAt, + }) + .from(awsMarketplaceLicense) + .where( + and( + inArray(awsMarketplaceLicense.productCode, [...usageByProduct.keys()]), + isNotNull(awsMarketplaceLicense.organizationId), + isNotNull(awsMarketplaceLicense.organizationLinkedAt), + or( + eq(awsMarketplaceLicense.status, 'active'), + gte(awsMarketplaceLicense.endedAt, new Date(now - AFTER_END_MS)) + ) + ) + ) + .orderBy(asc(awsMarketplaceLicense.organizationLinkedAt), asc(awsMarketplaceLicense.id)) + return rows.flatMap(({ productCode, organizationId, organizationLinkedAt, ...row }) => { + const usage = usageByProduct.get(productCode) + if (!usage || !organizationId || !organizationLinkedAt) return [] + return [{ ...row, organizationId, organizationLinkedAt, usage }] + }) +} + +/** + * When a license stopped being in force, or null while it still is: the earlier of its expiry, once + * passed, and the moment a check first found it inactive. + */ +function licenseEnd(license: MeteredLicense, now: number): number | null { + const expiry = license.expiresAt?.getTime() + const ends = [ + ...(expiry !== undefined && expiry <= now ? [expiry] : []), + ...(license.status === 'active' ? [] : [license.endedAt?.getTime() ?? now]), + ] + return ends.length > 0 ? Math.min(...ends) : null +} + +/** + * The license an organization's newly counted usage is charged to, and the hour it is charged in. + * A license still in force wins, the earliest-linked first, so overlapping licenses during a + * renewal never both bill. With none in force, the license that ended most recently, within the + * hour AWS allows, takes the usage at the hour it ended. That record is sent as soon as the end is + * known, because AWS counts its hour from the unsubscribe itself, which a check only finds later: + * usage that lands after the send, from work still finishing once access is gone, is not billed. + */ +function chargeTarget( + licenses: MeteredLicense[], + now: number +): { license: MeteredLicense; hourStart: number } | null { + let ended: { license: MeteredLicense; end: number } | null = null + for (const license of licenses) { + const end = licenseEnd(license, now) + if (end === null) return { license, hourStart: floorHour(now) } + if (end >= now - AFTER_END_MS && (!ended || end > ended.end)) ended = { license, end } + } + return ended ? { license: ended.license, hourStart: floorHour(ended.end) } : null +} + +/** + * Counts the organization's usage in each billing period with a row still able to change, and + * returns the whole units not yet charged. Each period's tally is updated to what it now covers; a period's + * first tally fixes where its count starts. + */ +async function countNewUnits( + tx: DbTransaction, + organizationId: string, + usage: AwsMarketplaceUsagePricing, + countFrom: Date, + now: number +): Promise { + const tallies = await tx + .select() + .from(awsMarketplaceUsageTally) + .where( + and( + eq(awsMarketplaceUsageTally.organizationId, organizationId), + eq(awsMarketplaceUsageTally.dimension, usage.dimension) + ) + ) + const tallyByPeriod = new Map(tallies.map((tally) => [tally.periodStart.getTime(), tally])) + + const periods = await tx + .selectDistinct({ periodStart: usageLog.billingPeriodStart }) + .from(usageLog) + .where( + and( + eq(usageLog.billingEntityType, 'organization'), + eq(usageLog.billingEntityId, organizationId), + gt(usageLog.createdAt, new Date(now - ROW_SETTLE_MS)) + ) + ) + + let newUnits = 0 + for (const { periodStart } of periods) { + if (!periodStart) continue + const tally = tallyByPeriod.get(periodStart.getTime()) + const countedFrom = tally?.countedFrom ?? countFrom + const [{ cost }] = await tx + .select({ cost: sql`coalesce(sum(${usageLog.cost}), 0)` }) + .from(usageLog) + .where( + and( + eq(usageLog.billingEntityType, 'organization'), + eq(usageLog.billingEntityId, organizationId), + eq(usageLog.billingPeriodStart, periodStart), + inArray(usageLog.category, [...usage.categories]), + gte(usageLog.createdAt, countedFrom) + ) + ) + const periodCost = toDecimal(cost) + const counted = tally?.units ?? 0 + const units = Math.max(counted, periodCost.dividedToIntegerBy(usage.usdPerUnit).toNumber()) + newUnits += units - counted + await tx + .insert(awsMarketplaceUsageTally) + .values({ + organizationId, + dimension: usage.dimension, + periodStart, + countedFrom, + costUsd: toFixedString(periodCost), + units, + }) + .onConflictDoUpdate({ + target: [ + awsMarketplaceUsageTally.organizationId, + awsMarketplaceUsageTally.dimension, + awsMarketplaceUsageTally.periodStart, + ], + set: { costUsd: toFixedString(periodCost), units, updatedAt: new Date() }, + }) + } + return newUnits +} + +/** + * Charges one organization's newly counted usage to its license, under a lock on the organization + * and dimension so concurrent runs extend one tally. Returns the units charged. With no license + * to charge, or only a record already sent, the usage stays uncounted for the next license that + * can take it. + */ +async function chargeOrganization(licenses: MeteredLicense[], now: number): Promise { + const target = chargeTarget(licenses, now) + if (!target) return 0 + const { license, hourStart } = target + const { usage } = license + return db.transaction(async (tx) => { + await lockUsage(tx, license.organizationId, usage.dimension) + const [record] = await tx + .select({ + status: awsMarketplaceUsageReport.status, + sentAt: awsMarketplaceUsageReport.sentAt, + }) + .from(awsMarketplaceUsageReport) + .where( + and( + eq(awsMarketplaceUsageReport.licenseId, license.id), + eq(awsMarketplaceUsageReport.dimension, usage.dimension), + eq(awsMarketplaceUsageReport.hourStart, new Date(hourStart)) + ) + ) + if (record && (record.status !== 'pending' || record.sentAt)) return 0 + + const countFrom = licenses.reduce( + (earliest, entry) => + entry.organizationLinkedAt < earliest ? entry.organizationLinkedAt : earliest, + license.organizationLinkedAt + ) + const units = await countNewUnits(tx, license.organizationId, usage, countFrom, now) + if (units <= 0) return 0 + const [written] = await tx + .insert(awsMarketplaceUsageReport) + .values({ + id: generateId(), + organizationId: license.organizationId, + licenseId: license.id, + dimension: usage.dimension, + hourStart: new Date(hourStart), + quantity: units, + }) + .onConflictDoUpdate({ + target: [ + awsMarketplaceUsageReport.licenseId, + awsMarketplaceUsageReport.dimension, + awsMarketplaceUsageReport.hourStart, + ], + set: { + quantity: sql`${awsMarketplaceUsageReport.quantity} + ${units}`, + updatedAt: new Date(), + }, + setWhere: isNull(awsMarketplaceUsageReport.sentAt), + }) + .returning({ id: awsMarketplaceUsageReport.id }) + // A row frozen under another organization's lock (relinked after its organization was deleted) + // keeps its quantity; rolling back leaves these units for the next record. + if (!written) throw new Error('AWS Marketplace usage record was sent while it was charged') + return units + }) +} + +/** + * Rows AWS can no longer accept are rejected, so they stop being retried and the team hears: those + * 24 hours old, and the previous month's once its grace period has passed. + */ +async function rejectExpiredReports(now: number): Promise { + const today = new Date(now) + const monthStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1) + const tooOld: SQL[] = [lte(awsMarketplaceUsageReport.hourStart, new Date(now - AWS_ACCEPTS_MS))] + if (now >= monthStart + MONTH_GRACE_MS) { + tooOld.push(lt(awsMarketplaceUsageReport.hourStart, new Date(monthStart))) + } + const expired = await db + .update(awsMarketplaceUsageReport) + .set({ + status: 'rejected', + error: 'Outside the window AWS accepts usage for', + updatedAt: new Date(), + }) + .where(and(eq(awsMarketplaceUsageReport.status, 'pending'), or(...tooOld))) + .returning({ + id: awsMarketplaceUsageReport.id, + organizationId: awsMarketplaceUsageReport.organizationId, + quantity: awsMarketplaceUsageReport.quantity, + }) + for (const row of expired) { + logger.error('AWS Marketplace usage aged out before AWS accepted it', { ...row }) + } + return expired.length +} + +async function settleReport( + row: PendingReport, + outcome: + | { status: 'reported'; meteringRecordId: string | null } + | { status: 'rejected'; error: string } +): Promise { + const now = new Date() + await db + .update(awsMarketplaceUsageReport) + .set( + outcome.status === 'reported' + ? { + status: 'reported', + meteringRecordId: outcome.meteringRecordId, + reportedAt: now, + updatedAt: now, + } + : { status: 'rejected', error: outcome.error, updatedAt: now } + ) + .where(eq(awsMarketplaceUsageReport.id, row.id)) +} + +function recordKey(dimension: string | undefined, timestamp: Date | undefined): string { + return `${dimension}@${timestamp?.getTime()}` +} + +/** + * Fixes the quantity of rows about to go to AWS and returns them as they now stand, dropping any + * no longer pending. The usage lock keeps a charge from joining a row between this read and the + * send. + */ +async function freezeForSending(rows: PendingReport[]): Promise { + return db.transaction(async (tx) => { + const scopes = new Map() + for (const { organizationId, dimension } of rows) { + if (organizationId) + scopes.set(`${organizationId}:${dimension}`, { organizationId, dimension }) + } + const ordered = [...scopes].sort(([left], [right]) => compareStrings(left, right)) + for (const [, scope] of ordered) await lockUsage(tx, scope.organizationId, scope.dimension) + const frozen = await tx + .update(awsMarketplaceUsageReport) + .set({ sentAt: sql`coalesce(${awsMarketplaceUsageReport.sentAt}, now())` }) + .where( + and( + inArray( + awsMarketplaceUsageReport.id, + rows.map((row) => row.id) + ), + eq(awsMarketplaceUsageReport.status, 'pending') + ) + ) + .returning({ id: awsMarketplaceUsageReport.id, quantity: awsMarketplaceUsageReport.quantity }) + const quantityById = new Map(frozen.map((row) => [row.id, row.quantity])) + return rows.flatMap((row) => { + const quantity = quantityById.get(row.id) + return quantity === undefined ? [] : [{ ...row, quantity }] + }) + }) +} + +/** + * Sends one call's worth of a license's rows. A license-level refusal rejects them and is logged; + * an out-of-range timestamp, which fails the whole call, is narrowed to its own row by sending + * each alone; a record AWS leaves unprocessed, or any other failure, stays pending for the next + * run. + */ +async function sendBatch( + batch: PendingReport[], + summary: AwsMarketplaceMeteringSummary +): Promise { + const unsettled = new Map(batch.map((row) => [recordKey(row.dimension, row.hourStart), row])) + try { + const response = await getMeteringClient().send( + new BatchMeterUsageCommand({ + UsageRecords: batch.map((row) => ({ + LicenseArn: row.licenseArn, + CustomerAWSAccountId: row.customerAwsAccountId, + Dimension: row.dimension, + Quantity: row.quantity, + Timestamp: row.hourStart, + })), + }) + ) + for (const result of response.Results ?? []) { + const key = recordKey(result.UsageRecord?.Dimension, result.UsageRecord?.Timestamp) + const row = unsettled.get(key) + if (!row) continue + unsettled.delete(key) + if (result.Status === 'CustomerNotSubscribed') { + logger.error('AWS Marketplace refused usage for a customer not subscribed', { + reportId: row.id, + organizationId: row.organizationId, + }) + await settleReport(row, { status: 'rejected', error: 'CustomerNotSubscribed' }) + summary.rejected += 1 + } else { + await settleReport(row, { + status: 'reported', + meteringRecordId: result.MeteringRecordId ?? null, + }) + summary.reported += 1 + } + } + summary.retained += unsettled.size + } catch (error) { + const { name } = toError(error) + if (name === TIMESTAMP_OUT_OF_BOUNDS && batch.length > 1) { + for (const row of batch) await sendBatch([row], summary) + return + } + if (name === TIMESTAMP_OUT_OF_BOUNDS || LICENSE_LEVEL_ERRORS.has(name)) { + logger.error('AWS Marketplace refused usage', { + reason: name, + reportIds: batch.map((row) => row.id), + }) + for (const row of batch) await settleReport(row, { status: 'rejected', error: name }) + summary.rejected += batch.length + return + } + logger.warn('AWS Marketplace usage could not be sent; retrying next run', { + reason: name, + error: getErrorMessage(error), + }) + summary.retained += batch.length + } +} + +/** + * Sends every pending row whose hour has closed, or whose license has ended or expired so no more + * usage can join it, oldest first and grouped by license. + */ +async function sendPendingReports(now: number, summary: AwsMarketplaceMeteringSummary) { + summary.rejected += await rejectExpiredReports(now) + const pending: PendingReport[] = await db + .select({ + id: awsMarketplaceUsageReport.id, + organizationId: awsMarketplaceUsageReport.organizationId, + dimension: awsMarketplaceUsageReport.dimension, + hourStart: awsMarketplaceUsageReport.hourStart, + quantity: awsMarketplaceUsageReport.quantity, + licenseArn: awsMarketplaceLicense.licenseArn, + customerAwsAccountId: awsMarketplaceLicense.customerAwsAccountId, + }) + .from(awsMarketplaceUsageReport) + .innerJoin( + awsMarketplaceLicense, + eq(awsMarketplaceUsageReport.licenseId, awsMarketplaceLicense.id) + ) + .where( + and( + eq(awsMarketplaceUsageReport.status, 'pending'), + or( + lt(awsMarketplaceUsageReport.hourStart, new Date(floorHour(now))), + eq(awsMarketplaceLicense.status, 'inactive'), + lte(awsMarketplaceLicense.expiresAt, new Date(now)) + ) + ) + ) + .orderBy(asc(awsMarketplaceUsageReport.hourStart)) + .limit(MAX_ROWS_PER_RUN) + + const byLicense = new Map() + for (const row of pending) { + const rows = byLicense.get(row.licenseArn) + if (rows) rows.push(row) + else byLicense.set(row.licenseArn, [row]) + } + for (const rows of byLicense.values()) { + for (const chunk of chunkArray(rows, RECORDS_PER_CALL)) { + const batch = await freezeForSending(chunk) + if (batch.length > 0) await sendBatch(batch, summary) + } + } +} + +/** + * Charges every organization's newly counted usage owed through AWS Marketplace and sends AWS the + * records it can take. Null when no configured listing bills usage. Safe to replay: a period's + * tally only moves forward, and AWS keeps the first record for a license, dimension and hour. + */ +export async function meterAwsMarketplaceUsage( + now = Date.now() +): Promise { + const usageByProduct = new Map() + for (const [productCode, product] of getAwsMarketplaceProducts()) { + if (product.plan.usage) usageByProduct.set(productCode, product.plan.usage) + } + if (usageByProduct.size === 0) return null + const summary: AwsMarketplaceMeteringSummary = { + charged: 0, + reported: 0, + rejected: 0, + retained: 0, + } + await stampUnmarkedLinks([...usageByProduct.keys()], now) + const licensesByOrganization = new Map() + for (const license of await listMeteredLicenses(usageByProduct, now)) { + const key = `${license.organizationId}:${license.usage.dimension}` + const licenses = licensesByOrganization.get(key) + if (licenses) licenses.push(license) + else licensesByOrganization.set(key, [license]) + } + for (const licenses of licensesByOrganization.values()) { + summary.charged += await chargeOrganization(licenses, now) + } + await sendPendingReports(now, summary) + return summary +} diff --git a/apps/sim/lib/billing/aws-marketplace/notifications.ts b/apps/sim/lib/billing/aws-marketplace/notifications.ts index 81957e7dd3e..ee9baac9d4e 100644 --- a/apps/sim/lib/billing/aws-marketplace/notifications.ts +++ b/apps/sim/lib/billing/aws-marketplace/notifications.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { findAwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/config' import { AWS_MARKETPLACE_BUYER_NOTICE_EVENT, AWS_MARKETPLACE_TEAM_NOTICE_EVENT, @@ -60,6 +61,7 @@ async function describeLicense(license: AwsMarketplaceLicenseRecord): Promise = new Map( + PLANS.map((plan) => [plan.id, plan]) +) + +export interface AwsMarketplaceProduct { + readonly productCode: string + readonly plan: AwsMarketplacePlan +} + +/** The products a deployment sells, by product code. */ +export type AwsMarketplaceProducts = ReadonlyMap + +/** + * Parses `AWS_MARKETPLACE_PRODUCTS`: comma-separated `=` pairs, for example + * `abc123=contract,def456=platform-with-usage`. Throws on a malformed entry, an unknown plan, or a + * repeated code. + */ +export function parseAwsMarketplaceProducts(value: string): AwsMarketplaceProducts { + const products = new Map() + for (const entry of value.split(',')) { + const [productCode, planId, ...rest] = entry.split('=').map((part) => part.trim()) + if (!productCode || !planId || rest.length > 0) { + throw new Error( + `AWS_MARKETPLACE_PRODUCTS: expected =, got "${entry.trim()}"` + ) + } + const plan = AWS_MARKETPLACE_PLANS.get(planId) + if (!plan) { + throw new Error( + `AWS_MARKETPLACE_PRODUCTS: unknown plan "${planId}"; expected one of ${[...AWS_MARKETPLACE_PLANS.keys()].join(', ')}` + ) + } + if (products.has(productCode)) { + throw new Error(`AWS_MARKETPLACE_PRODUCTS: product code "${productCode}" is listed twice`) + } + products.set(productCode, { productCode, plan }) + } + return products +} diff --git a/apps/sim/lib/billing/aws-marketplace/reconcile.ts b/apps/sim/lib/billing/aws-marketplace/reconcile.ts index 5c9a8dc6fea..8a58828a22b 100644 --- a/apps/sim/lib/billing/aws-marketplace/reconcile.ts +++ b/apps/sim/lib/billing/aws-marketplace/reconcile.ts @@ -10,11 +10,15 @@ import { getErrorMessage } from '@sim/utils/errors' import { toRecord } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { getEventsQueueClient } from '@/lib/billing/aws-marketplace/client' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config' import { isInvalidEntitlementRequest, verifyLicense, } from '@/lib/billing/aws-marketplace/entitlements' +import type { + AwsMarketplaceProduct, + AwsMarketplaceProducts, +} from '@/lib/billing/aws-marketplace/products' import { applyAgreementRenewal, enqueueAgreementAdvisory, @@ -80,7 +84,8 @@ interface AwsMarketplaceReconcileSummary { type BudgetCheck = (workMs: number) => boolean interface DrainContext { - productCode: string + /** This run's configured products, by code; an event for any other product is not ours. */ + products: AwsMarketplaceProducts queueUrl: string summary: AwsMarketplaceReconcileSummary fits: BudgetCheck @@ -159,25 +164,23 @@ type MessageOutcome = 'applied' | 'discarded' | 'skipped' | 'retained' /** Stamps the attempt, asks the Entitlement Service, and records its answer. */ async function reverifyLicense( - productCode: string, + product: AwsMarketplaceProduct, licenseArn: string, - options: { - ended?: boolean - agreement?: { agreementId: string | null; seenAt: Date | null } - fallbackAccountId?: string | null - licenseEventReceivedAt?: Date - } = {} + options: Pick< + Parameters[0], + 'agreement' | 'fallbackAccountId' | 'licenseEventReceivedAt' + > & { ended?: boolean } = {} ) { const verifiedAt = new Date() await markVerificationAttempted(licenseArn, verifiedAt) const summary = await verifyLicense( - productCode, + product, licenseArn, AbortSignal.timeout(LICENSE_READ_BUDGET_MS) ) const outcome = await recordLicenseVerification({ licenseArn, - productCode, + productCode: product.productCode, verification: { summary, verifiedAt, ended: options.ended }, agreement: options.agreement, fallbackAccountId: options.fallbackAccountId, @@ -220,20 +223,20 @@ async function applyAgreementEnded( message: Message ): Promise { const messageId = message.MessageId - const licenseArns = await listLicensesForAgreement(context.productCode, agreementId) - if (licenseArns.length === 0) { + const licenses = await listLicensesForAgreement(context.products, agreementId) + if (licenses.length === 0) { logger.info('Ignoring an AWS Marketplace agreement event for no stored license', { messageId }) return 'discarded' } let incomplete = false - for (const [index, licenseArn] of licenseArns.entries()) { + for (const [index, { licenseArn, product }] of licenses.entries()) { if (!context.fits(LICENSE_READ_BUDGET_MS)) { context.summary.outOfBudget = true return 'retained' } if (index > 0) await extendVisibility(context, message, messageId) try { - const { summary } = await reverifyLicense(context.productCode, licenseArn, { ended: true }) + const { summary } = await reverifyLicense(product, licenseArn, { ended: true }) if (summary.active) { incomplete = true logger.info('AWS still reports a license of an ended agreement current', { messageId }) @@ -261,8 +264,8 @@ async function applyLicenseEvent( event: Extract, messageId: string | undefined ): Promise { - const { productCode } = context - if (event.productCode !== productCode) { + const product = event.productCode ? context.products.get(event.productCode) : undefined + if (!product) { logger.warn('Discarding an AWS Marketplace event for another product', { messageId }) return 'discarded' } @@ -272,7 +275,7 @@ async function applyLicenseEvent( }) return 'retained' } - const { summary, outcome } = await reverifyLicense(productCode, event.licenseArn, { + const { summary, outcome } = await reverifyLicense(product, event.licenseArn, { ended: event.deprovisioned, agreement: { agreementId: event.agreementId, seenAt: event.occurredAt }, fallbackAccountId: event.acceptorAccountId, @@ -298,34 +301,41 @@ async function applyLicenseEvent( return 'applied' } +/** Whether a stored license was involved in an advisory or a renewal. */ +async function applyAgreementEvent( + products: AwsMarketplaceProducts, + event: Extract, + messageId: string | undefined +): Promise { + if (event.kind === 'advisory') { + return enqueueAgreementAdvisory({ + products, + agreementId: event.agreementId, + state: event.state, + detail: event.detail, + messageId: messageId ?? event.agreementId, + }) + } + if (!event.previousAgreementId) return false + return applyAgreementRenewal({ + products, + previousAgreementId: event.previousAgreementId, + agreementId: event.agreementId, + }) +} + async function applyEvent( context: DrainContext, event: MarketplaceEvent, message: Message ): Promise { - const { productCode } = context + const { products } = context const messageId = message.MessageId if (event.kind === 'license') return applyLicenseEvent(context, event, messageId) if (event.kind === 'agreement-ended') { return applyAgreementEnded(context, event.agreementId, message) } - const involved = - event.kind === 'advisory' - ? await enqueueAgreementAdvisory({ - productCode, - agreementId: event.agreementId, - state: event.state, - detail: event.detail, - messageId: messageId ?? event.agreementId, - }) - : event.previousAgreementId - ? await applyAgreementRenewal({ - productCode, - previousAgreementId: event.previousAgreementId, - agreementId: event.agreementId, - }) - : false - if (involved) return 'applied' + if (await applyAgreementEvent(products, event, messageId)) return 'applied' logger.info('Ignoring an AWS Marketplace agreement event for no stored license', { messageId }) return 'discarded' } @@ -412,19 +422,19 @@ async function drainEvents(context: DrainContext): Promise { } async function reverifyStaleLicenses( - productCode: string, + products: AwsMarketplaceProducts, summary: AwsMarketplaceReconcileSummary, fits: BudgetCheck ): Promise { const cutoff = new Date(Date.now() - REVERIFY_AFTER_MS) - const licenseArns = await listLicensesDueForReverify(productCode, cutoff, REVERIFY_BATCH_SIZE) - for (const licenseArn of licenseArns) { + const licenses = await listLicensesDueForReverify(products, cutoff, REVERIFY_BATCH_SIZE) + for (const { licenseArn, product } of licenses) { if (!fits(LICENSE_READ_BUDGET_MS)) { summary.outOfBudget = true return } try { - await reverifyLicense(productCode, licenseArn) + await reverifyLicense(product, licenseArn) summary.reverified++ } catch (error) { summary.reverifyFailed++ @@ -442,8 +452,8 @@ async function reverifyStaleLicenses( * feature is off. */ export async function reconcileAwsMarketplaceLicenses(): Promise { - const productCode = getAwsMarketplaceProductCode() - if (!productCode) return null + const products = getAwsMarketplaceProducts() + if (products.size === 0) return null const summary: AwsMarketplaceReconcileSummary = { received: 0, applied: 0, @@ -457,7 +467,7 @@ export async function reconcileAwsMarketplaceLicenses(): Promise Date.now() - startedAt + workMs <= RUN_BUDGET_MS const queueUrl = env.AWS_MARKETPLACE_EVENTS_QUEUE_URL - if (queueUrl) await drainEvents({ productCode, queueUrl, summary, fits }) - await reverifyStaleLicenses(productCode, summary, fits) + if (queueUrl) await drainEvents({ products, queueUrl, summary, fits }) + await reverifyStaleLicenses(products, summary, fits) return summary } diff --git a/apps/sim/lib/billing/aws-marketplace/registration.ts b/apps/sim/lib/billing/aws-marketplace/registration.ts index 0bbce158a0b..61888f75581 100644 --- a/apps/sim/lib/billing/aws-marketplace/registration.ts +++ b/apps/sim/lib/billing/aws-marketplace/registration.ts @@ -7,8 +7,10 @@ import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { AWS_MARKETPLACE_CLAIM_TTL_MS, issueClaimToken } from '@/lib/billing/aws-marketplace/claim' import { getMeteringClient } from '@/lib/billing/aws-marketplace/client' +import { findAwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/config' import { type EntitlementSummary, verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' import type { AwsMarketplaceOnboardingError } from '@/lib/billing/aws-marketplace/onboarding' +import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products' import { saveRegisteredLicense } from '@/lib/billing/aws-marketplace/repository' const logger = createLogger('AwsMarketplaceRegistration') @@ -27,30 +29,30 @@ function errorName(error: unknown): string | undefined { return error instanceof Error ? error.name : undefined } -/** AWS may not have finished right after a purchase, so an empty answer is asked again briefly. */ +/** AWS may not have finished right after a purchase, so a read with no answer about access is asked again briefly. */ async function readEntitlements( - productCode: string, + product: AwsMarketplaceProduct, licenseArn: string ): Promise<{ summary: EntitlementSummary; verifiedAt: Date }> { let verifiedAt = new Date() - let summary = await verifyLicense(productCode, licenseArn) + let summary = await verifyLicense(product, licenseArn) for (const delayMs of NO_ENTITLEMENT_RETRY_DELAYS_MS) { - if (summary.entitlementCount > 0) break + if (summary.accessReported) break await sleep(delayMs) verifiedAt = new Date() - summary = await verifyLicense(productCode, licenseArn) + summary = await verifyLicense(product, licenseArn) } return { summary, verifiedAt } } /** - * Turns the registration token AWS posts to the fulfillment URL into a license row. Every - * registration reads the license's entitlements. A license AWS has not confirmed yet still gets a - * claim, so the buyer can leave contact details; only a lapsed one does not. The token itself is - * never logged. + * Turns the registration token AWS posts to the fulfillment URL into a license row. Every listing + * shares the one fulfillment URL, so the product comes from the code AWS resolves the token to, + * and a code this deployment does not sell is refused. Every registration reads the license's + * entitlements. A license AWS has not confirmed yet still gets a claim, so the buyer can leave + * contact details; only a lapsed one does not. The token itself is never logged. */ export async function registerAwsMarketplaceBuyer( - productCode: string, registrationToken: string ): Promise { let customer: ResolveCustomerCommandOutput @@ -68,7 +70,8 @@ export async function registerAwsMarketplaceBuyer( return { kind: 'unavailable' } } - if (customer.ProductCode !== productCode) { + const product = findAwsMarketplaceProduct(customer.ProductCode) + if (!product) { logger.warn('AWS Marketplace registration is for another product') return { kind: 'invalid' } } @@ -84,11 +87,11 @@ export async function registerAwsMarketplaceBuyer( } try { - const verification = await readEntitlements(productCode, customer.LicenseArn) + const verification = await readEntitlements(product, customer.LicenseArn) const claim = issueClaimToken() const saved = await saveRegisteredLicense({ licenseArn: customer.LicenseArn, - productCode, + productCode: product.productCode, customerAwsAccountId: customer.CustomerAWSAccountId, agreementId: customer.Metadata?.AgreementId ?? null, verification, diff --git a/apps/sim/lib/billing/aws-marketplace/repository.ts b/apps/sim/lib/billing/aws-marketplace/repository.ts index 0b3a8ff2ef4..15636142926 100644 --- a/apps/sim/lib/billing/aws-marketplace/repository.ts +++ b/apps/sim/lib/billing/aws-marketplace/repository.ts @@ -1,7 +1,27 @@ import { db } from '@sim/db' -import { awsMarketplaceLicense, member, organization, outboxEvent, user } from '@sim/db/schema' +import { + awsMarketplaceLicense, + member, + organization, + outboxEvent, + subscription, + user, +} from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, asc, count, desc, eq, inArray, isNotNull, isNull, lt, ne, or } from 'drizzle-orm' +import { + and, + asc, + count, + desc, + eq, + inArray, + isNotNull, + isNull, + lt, + ne, + notInArray, + or, +} from 'drizzle-orm' import type { EntitlementSummary } from '@/lib/billing/aws-marketplace/entitlements' import { AWS_MARKETPLACE_BUYER_NOTICE_EVENT, @@ -10,6 +30,11 @@ import { type AwsMarketplaceNoticeTerms, type AwsMarketplaceTeamNotice, } from '@/lib/billing/aws-marketplace/notice-events' +import type { + AwsMarketplaceProduct, + AwsMarketplaceProducts, +} from '@/lib/billing/aws-marketplace/products' +import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbTransaction } from '@/lib/db/types' @@ -40,6 +65,20 @@ function enqueueTeamNotice(tx: DbTransaction, notice: AwsMarketplaceTeamNotice, return enqueueOutboxEvent(tx, AWS_MARKETPLACE_TEAM_NOTICE_EVENT, notice, { id }) } +/** Enqueues a team notice under a fixed id unless one with that id was already sent. */ +async function enqueueTeamNoticeOnce( + tx: DbTransaction, + notice: AwsMarketplaceTeamNotice, + id: string +): Promise { + const [sent] = await tx + .select({ id: outboxEvent.id }) + .from(outboxEvent) + .where(eq(outboxEvent.id, id)) + .limit(1) + if (!sent) await enqueueTeamNotice(tx, notice, id) +} + function enqueueBuyerNotice(tx: DbTransaction, notice: AwsMarketplaceBuyerNotice) { return enqueueOutboxEvent(tx, AWS_MARKETPLACE_BUYER_NOTICE_EVENT, notice) } @@ -50,21 +89,22 @@ export function isNewerRead(license: AwsMarketplaceLicenseRecord, verifiedAt: Da } /** - * No entitlements after AWS has said the license ended, or on a license that was already confirmed - * once, is a lapse. Otherwise AWS may simply not have finished, so the license stays pending. + * No answer about access after AWS has said the license ended, or on a license that was already + * confirmed once, is a lapse. Otherwise AWS may simply not have finished, so the license stays pending. */ function statusFromRead( prior: AwsMarketplaceLicenseStatus | null, { summary, ended }: LicenseVerification ): AwsMarketplaceLicenseStatus { if (summary.active) return 'active' - if (summary.entitlementCount > 0 || ended) return 'inactive' + if (summary.accessReported || ended) return 'inactive' return prior === null || prior === 'pending' ? 'pending' : 'inactive' } /** - * The columns a read writes. With no entitlements the last-known dimension, quantity, and expiry - * stay as they were, and only the status can change. An inactive license has nothing to claim. + * The columns a read writes. With no answer about access the last-known dimension, quantity, and + * expiry stay as they were, and only the status can change. The read that first finds a license + * inactive records when it ended. */ function verificationFields( prior: AwsMarketplaceLicenseStatus | null, @@ -74,18 +114,23 @@ function verificationFields( const { summary, verifiedAt } = verification return { status, + ...(status === 'inactive' + ? prior !== 'inactive' && { endedAt: verifiedAt } + : { endedAt: null }), lastVerifiedAt: verifiedAt, lastVerificationAttemptAt: verifiedAt, updatedAt: new Date(), - ...(summary.entitlementCount > 0 && { + ...(summary.accessReported && { dimension: summary.dimension, quantity: summary.quantity, expiresAt: summary.expiresAt, }), - ...(status === 'inactive' && { claimTokenHash: null, claimExpiresAt: null }), } } +/** An inactive license has nothing to claim, whatever else the writer sets. */ +const NO_CLAIM = { claimTokenHash: null, claimExpiresAt: null } + function termsOf(license: AwsMarketplaceLicenseRecord): AwsMarketplaceNoticeTerms { return { status: license.status, @@ -107,7 +152,8 @@ function termsDiffer(before: AwsMarketplaceNoticeTerms, after: AwsMarketplaceNot /** * The one place a read reaches a stored license, for every writer: under the caller's row lock it * applies the read unless a newer one is stored, and when that moves the terms, enqueues the team's - * notice in the same transaction. `alongside` is written either way. + * notice in the same transaction. `alongside` is written either way, except a claim on a license + * that ends up inactive. */ async function applyVerification( tx: DbTransaction, @@ -116,11 +162,13 @@ async function applyVerification( alongside: Partial = {} ): Promise { const fresh = isNewerRead(locked, verification.verifiedAt) + const status = fresh ? statusFromRead(locked.status, verification) : locked.status const [license] = await tx .update(awsMarketplaceLicense) .set({ ...(fresh ? verificationFields(locked.status, verification) : { updatedAt: new Date() }), ...alongside, + ...(status === 'inactive' && NO_CLAIM), }) .where(eq(awsMarketplaceLicense.id, locked.id)) .returning() @@ -182,7 +230,7 @@ async function inheritFromRenewedLicense( const [inherited] = await tx .update(awsMarketplaceLicense) .set({ - organizationId: predecessor.organizationId, + ...organizationLink(predecessor.organizationId), linkedByUserId: predecessor.linkedByUserId, contactEmail: predecessor.contactEmail, companyName: predecessor.companyName, @@ -193,6 +241,43 @@ async function inheritFromRenewedLicense( return inherited } +/** + * Creates the row for a license nobody stored yet, carrying on a renewed license's owner, and tells + * the team. Null when the ARN already has a row, which the caller then updates under its lock. + */ +async function insertNewLicense( + tx: DbTransaction, + input: { + licenseArn: string + productCode: string + customerAwsAccountId: string + seen: AgreementObservation + verification: LicenseVerification + alongside: Partial + }, + source: 'registration' | 'event' +): Promise { + const status = statusFromRead(null, input.verification) + const [created] = await tx + .insert(awsMarketplaceLicense) + .values({ + id: generateId(), + licenseArn: input.licenseArn, + productCode: input.productCode, + customerAwsAccountId: input.customerAwsAccountId, + ...agreementFields(null, input.seen), + ...input.alongside, + ...verificationFields(null, input.verification), + ...(status === 'inactive' && NO_CLAIM), + }) + .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn }) + .returning() + if (!created) return null + const license = await inheritFromRenewedLicense(tx, created) + await enqueueTeamNotice(tx, { kind: 'new-license', licenseId: license.id, source }) + return license +} + /** * Records a registration: the license row keyed by its ARN, with `claim` unless the newest answer * is inactive. A new row tells the team at once, so a buyer who pays and abandons setup is seen. @@ -209,45 +294,25 @@ export async function saveRegisteredLicense(input: { const claimFields = { claimTokenHash: claim.hash, claimExpiresAt: claim.expiresAt } const seen = { agreementId: input.agreementId, seenAt: verification.verifiedAt } return db.transaction(async (tx) => { - const status = statusFromRead(null, verification) - const [created] = await tx - .insert(awsMarketplaceLicense) - .values({ - id: generateId(), - licenseArn: input.licenseArn, - productCode: input.productCode, - customerAwsAccountId: input.customerAwsAccountId, - ...agreementFields(null, seen), - ...(status === 'inactive' ? {} : claimFields), - ...verificationFields(null, verification), - }) - .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn }) - .returning() - if (created) { - const license = await inheritFromRenewedLicense(tx, created) - await enqueueTeamNotice(tx, { - kind: 'new-license', - licenseId: license.id, - source: 'registration', - }) - return { id: license.id, status: license.status } - } + const created = await insertNewLicense( + tx, + { ...input, seen, alongside: claimFields }, + 'registration' + ) + if (created) return { id: created.id, status: created.status } const locked = await lockByArn(tx, input.licenseArn) if (!locked) throw new Error('AWS Marketplace license vanished during registration') - const effective = isNewerRead(locked, verification.verifiedAt) - ? statusFromRead(locked.status, verification) - : locked.status const written = await applyVerification(tx, locked, verification, { ...agreementFields(locked, seen), - ...(effective === 'inactive' ? {} : claimFields), + ...claimFields, }) const license = await inheritFromRenewedLicense(tx, written) return { id: license.id, status: license.status } }) } -export type LicenseVerificationOutcome = +type LicenseVerificationOutcome = | { kind: 'created' | 'updated'; license: AwsMarketplaceLicenseRecord } /** No row, and AWS described nothing to create one from. */ | { kind: 'skipped' } @@ -273,25 +338,20 @@ export async function recordLicenseVerification(input: { const stamp = licenseEventReceivedAt ? { licenseEventReceivedAt } : {} return db.transaction(async (tx) => { const accountId = verification.summary.customerAwsAccountId ?? input.fallbackAccountId - if (accountId && verification.summary.entitlementCount > 0) { - const [created] = await tx - .insert(awsMarketplaceLicense) - .values({ - id: generateId(), + if (accountId && verification.summary.licenseSeen) { + const created = await insertNewLicense( + tx, + { licenseArn: input.licenseArn, productCode: input.productCode, customerAwsAccountId: accountId, - ...agreementFields(null, seen), - ...stamp, - ...verificationFields(null, verification), - }) - .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn }) - .returning() - if (created) { - const license = await inheritFromRenewedLicense(tx, created) - await enqueueTeamNotice(tx, { kind: 'new-license', licenseId: license.id, source: 'event' }) - return { kind: 'created', license } - } + seen, + verification, + alongside: stamp, + }, + 'event' + ) + if (created) return { kind: 'created', license: created } } const locked = await lockByArn(tx, input.licenseArn) if (!locked) return { kind: 'skipped' } @@ -323,24 +383,16 @@ export async function recordLicenseVerification(input: { * Returns whether any stored license was involved. */ export async function applyAgreementRenewal(input: { - productCode: string + products: AwsMarketplaceProducts previousAgreementId: string agreementId: string }): Promise { - const { productCode, previousAgreementId, agreementId } = input + const { products, previousAgreementId, agreementId } = input return db.transaction(async (tx) => { const predecessors = await tx .select() .from(awsMarketplaceLicense) - .where( - and( - eq(awsMarketplaceLicense.productCode, productCode), - or( - eq(awsMarketplaceLicense.agreementId, previousAgreementId), - eq(awsMarketplaceLicense.renewedAgreementId, previousAgreementId) - ) - ) - ) + .where(belongsToAgreement(products, previousAgreementId)) .for('update') const accepted = predecessors.filter( (row) => row.renewedAgreementId === null || row.renewedAgreementId === previousAgreementId @@ -359,37 +411,24 @@ export async function applyAgreementRenewal(input: { const successors = await tx .select() .from(awsMarketplaceLicense) - .where( - and( - eq(awsMarketplaceLicense.productCode, productCode), - eq(awsMarketplaceLicense.agreementId, agreementId) - ) - ) + .where(and(ofProducts(products), eq(awsMarketplaceLicense.agreementId, agreementId))) .for('update') for (const successor of successors) await inheritFromRenewedLicense(tx, successor) const licenseIds = [...accepted, ...successors].map((row) => row.id) if (licenseIds.length === 0) return false - const noticeId = `aws-marketplace-renewal-${agreementId}` - const [sent] = await tx - .select({ id: outboxEvent.id }) - .from(outboxEvent) - .where(eq(outboxEvent.id, noticeId)) - .limit(1) - if (!sent) { - await enqueueTeamNotice( - tx, - { kind: 'renewed', licenseIds, previousAgreementId, agreementId }, - noticeId - ) - } + await enqueueTeamNoticeOnce( + tx, + { kind: 'renewed', licenseIds, previousAgreementId, agreementId }, + `aws-marketplace-renewal-${agreementId}` + ) return true }) } /** Tells the team of an AWS advisory on an agreement we hold; redelivery cannot send it twice. */ export async function enqueueAgreementAdvisory(input: { - productCode: string + products: AwsMarketplaceProducts agreementId: string state: 'issued' | 'resolved' detail: string @@ -399,35 +438,31 @@ export async function enqueueAgreementAdvisory(input: { const rows = await tx .select({ id: awsMarketplaceLicense.id }) .from(awsMarketplaceLicense) - .where(belongsToAgreement(input.productCode, input.agreementId)) + .where(belongsToAgreement(input.products, input.agreementId)) .limit(MAX_LICENSES_PER_AGREEMENT) if (rows.length === 0) return false - const noticeId = `aws-marketplace-advisory-${input.messageId}` - const [sent] = await tx - .select({ id: outboxEvent.id }) - .from(outboxEvent) - .where(eq(outboxEvent.id, noticeId)) - .limit(1) - if (!sent) { - await enqueueTeamNotice( - tx, - { - kind: 'advisory', - licenseIds: rows.map((row) => row.id), - agreementId: input.agreementId, - state: input.state, - detail: input.detail, - }, - noticeId - ) - } + await enqueueTeamNoticeOnce( + tx, + { + kind: 'advisory', + licenseIds: rows.map((row) => row.id), + agreementId: input.agreementId, + state: input.state, + detail: input.detail, + }, + `aws-marketplace-advisory-${input.messageId}` + ) return true }) } -function belongsToAgreement(productCode: string, agreementId: string) { +function ofProducts(products: AwsMarketplaceProducts) { + return inArray(awsMarketplaceLicense.productCode, [...products.keys()]) +} + +function belongsToAgreement(products: AwsMarketplaceProducts, agreementId: string) { return and( - eq(awsMarketplaceLicense.productCode, productCode), + ofProducts(products), or( eq(awsMarketplaceLicense.agreementId, agreementId), eq(awsMarketplaceLicense.renewedAgreementId, agreementId) @@ -435,41 +470,67 @@ function belongsToAgreement(productCode: string, agreementId: string) { ) } +/** A license to re-read, with the product it was sold under. */ +interface LicenseToVerify { + licenseArn: string + product: AwsMarketplaceProduct +} + +const LICENSE_TO_VERIFY_COLUMNS = { + licenseArn: awsMarketplaceLicense.licenseArn, + productCode: awsMarketplaceLicense.productCode, +} + +/** + * The columns that point a license at `organizationId`, stamping when, since usage counts from + * the link. Every caller links only when the organization changes, so the stamp is the start of + * this organization's tenure on the license. + */ +function organizationLink(organizationId: string | null) { + return { organizationId, organizationLinkedAt: organizationId ? new Date() : null } +} + +/** Pairs each row with its product; the queries already keep to configured products. */ +function withProducts( + rows: Array<{ licenseArn: string; productCode: string }>, + products: AwsMarketplaceProducts +): LicenseToVerify[] { + return rows.flatMap(({ licenseArn, productCode }) => { + const product = products.get(productCode) + return product ? [{ licenseArn, product }] : [] + }) +} + /** * The licenses an agreement covers, least recently attempted first, so a redelivered message moves * on to the ones an earlier delivery did not reach. */ export async function listLicensesForAgreement( - productCode: string, + products: AwsMarketplaceProducts, agreementId: string -): Promise { +): Promise { const rows = await db - .select({ licenseArn: awsMarketplaceLicense.licenseArn }) + .select(LICENSE_TO_VERIFY_COLUMNS) .from(awsMarketplaceLicense) - .where(belongsToAgreement(productCode, agreementId)) + .where(belongsToAgreement(products, agreementId)) .orderBy(asc(awsMarketplaceLicense.lastVerificationAttemptAt)) .limit(MAX_LICENSES_PER_AGREEMENT) - return rows.map((row) => row.licenseArn) + return withProducts(rows, products) } -/** This product's licenses longest since an attempt, up to `limit`, none attempted after `cutoff`. */ +/** Licenses of these products longest since an attempt, up to `limit`, none after `cutoff`. */ export async function listLicensesDueForReverify( - productCode: string, + products: AwsMarketplaceProducts, cutoff: Date, limit: number -): Promise { +): Promise { const rows = await db - .select({ licenseArn: awsMarketplaceLicense.licenseArn }) + .select(LICENSE_TO_VERIFY_COLUMNS) .from(awsMarketplaceLicense) - .where( - and( - eq(awsMarketplaceLicense.productCode, productCode), - lt(awsMarketplaceLicense.lastVerificationAttemptAt, cutoff) - ) - ) + .where(and(ofProducts(products), lt(awsMarketplaceLicense.lastVerificationAttemptAt, cutoff))) .orderBy(asc(awsMarketplaceLicense.lastVerificationAttemptAt)) .limit(limit) - return rows.map((row) => row.licenseArn) + return withProducts(rows, products) } /** Stamps an attempt to verify a license, before its outcome is known. */ @@ -504,13 +565,14 @@ export async function lockLicense( return row ?? null } -interface BuyerContact { - email: string +/** Whom to contact about provisioning; AWS asks the registration page to collect it. */ +export interface BuyerContact { + contactEmail: string companyName: string | null } function contactChanged(locked: AwsMarketplaceLicenseRecord, contact: BuyerContact): boolean { - return locked.contactEmail !== contact.email || locked.companyName !== contact.companyName + return locked.contactEmail !== contact.contactEmail || locked.companyName !== contact.companyName } /** @@ -529,10 +591,10 @@ export async function consumeLicenseClaim( } ): Promise { const license = await applyVerification(tx, locked, input.verification, { - contactEmail: input.contact.email, + contactEmail: input.contact.contactEmail, companyName: input.contact.companyName, ...(input.link && { - organizationId: input.link.organizationId, + ...organizationLink(input.link.organizationId), linkedByUserId: input.link.userId, }), claimTokenHash: null, @@ -561,7 +623,7 @@ export async function saveLicenseContact( ): Promise { const changed = contactChanged(locked, input.contact) const license = await applyVerification(tx, locked, input.verification, { - contactEmail: input.contact.email, + contactEmail: input.contact.contactEmail, companyName: input.contact.companyName, }) if (changed) await enqueueTeamNotice(tx, { kind: 'contact', licenseId: license.id }) @@ -613,15 +675,20 @@ export async function listAccountLicenses(userId: string) { } /** - * Whether AWS Marketplace bills this billing reference: a license that is not inactive, linked to - * the organization, or registered to the user's account with no organization yet. + * Whether AWS Marketplace bills this billing reference: a license of a listing this deployment + * sells that is not inactive, linked to the organization, or registered to the user's account with + * no organization yet. */ -export async function holdsMarketplaceLicense(referenceId: string): Promise { +export async function holdsMarketplaceLicense( + products: AwsMarketplaceProducts, + referenceId: string +): Promise { const [row] = await db .select({ id: awsMarketplaceLicense.id }) .from(awsMarketplaceLicense) .where( and( + ofProducts(products), ne(awsMarketplaceLicense.status, 'inactive'), or( eq(awsMarketplaceLicense.organizationId, referenceId), @@ -666,12 +733,41 @@ export async function linkLicenseForTeam( ): Promise { const [license] = await tx .update(awsMarketplaceLicense) - .set({ organizationId, claimTokenHash: null, claimExpiresAt: null, updatedAt: new Date() }) + .set({ + ...organizationLink(organizationId), + claimTokenHash: null, + claimExpiresAt: null, + updatedAt: new Date(), + }) .where(eq(awsMarketplaceLicense.id, licenseId)) .returning() return license } +/** + * Whether the organization has a live Stripe subscription to a self-serve plan: any it is bound to + * that has not finished, since a trial or a payment still pending turns into a charge. Enterprise + * is provisioned by the team, who pause its collection for a Marketplace customer. + */ +export async function paysForSelfServePlan( + tx: DbTransaction, + organizationId: string +): Promise { + const [row] = await tx + .select({ id: subscription.id }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + isNotNull(subscription.stripeSubscriptionId), + notInArray(subscription.status, [...TERMINAL_SUBSCRIPTION_STATUSES]), + ne(subscription.plan, 'enterprise') + ) + ) + .limit(1) + return Boolean(row) +} + export async function organizationExists(tx: DbTransaction, organizationId: string) { const [row] = await tx .select({ id: organization.id }) @@ -717,10 +813,12 @@ export async function getLicensesByIds(licenseIds: readonly string[]) { .where(inArray(awsMarketplaceLicense.id, [...licenseIds])) } -/** Organizations already linked to another license of this AWS account, for the team to match. */ +/** + * Organizations already linked to another license of this AWS account, on any listing, for the + * team to match: a buyer moving to a new listing usually wants the organization they already have. + */ export async function listOrganizationsLinkedToAccount(input: { customerAwsAccountId: string - productCode: string excludingLicenseArn: string }): Promise> { return db @@ -730,7 +828,6 @@ export async function listOrganizationsLinkedToAccount(input: { .where( and( eq(awsMarketplaceLicense.customerAwsAccountId, input.customerAwsAccountId), - eq(awsMarketplaceLicense.productCode, input.productCode), ne(awsMarketplaceLicense.licenseArn, input.excludingLicenseArn) ) ) diff --git a/apps/sim/lib/block-metadata/names.generated.ts b/apps/sim/lib/block-metadata/names.generated.ts index 763ae48f0a0..c064312cb2b 100644 --- a/apps/sim/lib/block-metadata/names.generated.ts +++ b/apps/sim/lib/block-metadata/names.generated.ts @@ -12,7 +12,7 @@ export const BLOCK_NAMES: Readonly> = { ahrefs_v2: 'Ahrefs', airtable: 'Airtable', algolia_v2: 'Algolia', - amplitude: 'Amplitude', + amplitude_v2: 'Amplitude', api: 'API', apify_v2: 'Apify', apollo_v2: 'Apollo', @@ -22,7 +22,7 @@ export const BLOCK_NAMES: Readonly> = { ashby_v2: 'Ashby', athena_v2: 'Athena', attio: 'Attio', - azure_data_explorer: 'Azure Data Explorer', + azure_data_explorer_v2: 'Azure Data Explorer', azure_devops_v2: 'Azure DevOps', bitbucket: 'Bitbucket', box_v2: 'Box', @@ -33,7 +33,7 @@ export const BLOCK_NAMES: Readonly> = { buffer_v2: 'Buffer', calcom: 'Cal.com', calendly_v2: 'Calendly', - cbinsights: 'CB Insights', + cbinsights_v2: 'CB Insights', checkr_v2: 'Checkr', circleback_v2: 'Circleback', clay: 'Clay', @@ -149,7 +149,7 @@ export const BLOCK_NAMES: Readonly> = { jira_service_management: 'Jira Service Management', jotform_v2: 'Jotform', jupyter_v3: 'Jupyter', - kalshi_v2: 'Kalshi', + kalshi_v3: 'Kalshi', ketch: 'Ketch', knowledge: 'Knowledge', lambda_v2: 'Lambda', @@ -189,8 +189,8 @@ export const BLOCK_NAMES: Readonly> = { monday: 'Monday', mongodb: 'MongoDB', mothership: 'Sim Chat', - mssql: 'Microsoft SQL Server', - mysql: 'MySQL', + mssql_v2: 'Microsoft SQL Server', + mysql_v2: 'MySQL', neo4j: 'Neo4j', netsuite: 'Oracle NetSuite', neverbounce: 'NeverBounce', @@ -218,18 +218,18 @@ export const BLOCK_NAMES: Readonly> = { plane_v2: 'Plane', planetscale_v2: 'PlanetScale', polymarket: 'Polymarket', - postgresql: 'PostgreSQL', + postgresql_v2: 'PostgreSQL', posthog: 'PostHog', powerbi: 'Power BI', profound_v2: 'Profound', prospeo: 'Prospeo', pulse_v3: 'Pulse', - qdrant: 'Qdrant', + qdrant_v2: 'Qdrant', quartr_v2: 'Quartr', quickbooks: 'QuickBooks', quiver_v3: 'Quiver', rabbitmq: 'RabbitMQ', - railway: 'Railway', + railway_v2: 'Railway', ramp: 'Ramp', rb2b_v2: 'RB2B', rds_v2: 'Amazon RDS', @@ -269,7 +269,7 @@ export const BLOCK_NAMES: Readonly> = { smartlead_v2: 'Smartlead', smtp: 'SMTP', snowflake: 'Snowflake', - splunk: 'Splunk', + splunk_v2: 'Splunk', sportmonks_v2: 'Sportmonks', spotify: 'Spotify', sqs_v2: 'Amazon SQS', diff --git a/apps/sim/lib/catalog/projection/connector-type.ts b/apps/sim/lib/catalog/projection/connector-type.ts index 4e0c7ee6237..1f65fc62c60 100644 --- a/apps/sim/lib/catalog/projection/connector-type.ts +++ b/apps/sim/lib/catalog/projection/connector-type.ts @@ -16,7 +16,14 @@ import type { /** How a connector authenticates against its source. */ export type CatalogConnectorAuth = | { mode: 'oauth'; provider: string; requiredScopes?: string[] } - | { mode: 'apiKey'; label?: string; placeholder?: string; optional: boolean } + | { + mode: 'apiKey' + label?: string + placeholder?: string + optional: boolean + /** A saved credential of this service may be sent as `credentialId` instead of the key. */ + savedCredential?: { provider: string; label: string } + } /** * One field of a connector's `sourceConfig`. @@ -48,6 +55,8 @@ export interface CatalogConnectorConfigField { mode?: 'basic' | 'advanced' canonicalParamId?: string multi?: boolean + /** Applies only with the connector's API key; omit it when sending a saved `credentialId`. */ + apiKeyOnly?: boolean } /** A tag slot a connector populates on the documents it syncs. */ @@ -83,6 +92,14 @@ function projectAuth(auth: ConnectorMeta['auth']): CatalogConnectorAuth { optional: auth.optional === true, ...(auth.label !== undefined ? { label: auth.label } : {}), ...(auth.placeholder !== undefined ? { placeholder: auth.placeholder } : {}), + ...(auth.savedCredential + ? { + savedCredential: { + provider: auth.savedCredential.provider, + label: auth.savedCredential.label, + }, + } + : {}), } } @@ -120,6 +137,7 @@ function projectConfigField(field: ConnectorConfigField): CatalogConnectorConfig if (field.mode !== undefined) projected.mode = field.mode if (field.canonicalParamId !== undefined) projected.canonicalParamId = field.canonicalParamId if (field.multi !== undefined) projected.multi = field.multi + if (field.apiKeyOnly) projected.apiKeyOnly = true return projected } diff --git a/apps/sim/lib/computer-use/application/authorize.test.ts b/apps/sim/lib/computer-use/application/authorize.test.ts index b4549ca00b4..51afc06f4a5 100644 --- a/apps/sim/lib/computer-use/application/authorize.test.ts +++ b/apps/sim/lib/computer-use/application/authorize.test.ts @@ -7,23 +7,26 @@ import { organizationAuthorizationMock, organizationAuthorizationMockFns, } from '@sim/testing/mocks/organization-authorization.mock' +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing/mocks/permission-group-scope.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ ownedChat: vi.fn(), - available: vi.fn(), claim: vi.fn(), })) vi.mock('@/lib/mothership/chat/application/context', () => ({ resolveOwnedChatContext: mocks.ownedChat, })) -vi.mock('@/lib/computer-use/availability.server', () => ({ - isComputerUseAvailable: mocks.available, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) vi.mock('@/lib/computer-use/repository', () => ({ claimComputerUseTool: mocks.claim })) import { authorizeComputerUse } from '@/lib/computer-use/application/authorize' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const const input = { toolCallId: 'call-1' } @@ -56,7 +59,7 @@ describe('native computer authorization', () => { mothershipAsyncRunsMockFns.mockGetRunSegment.mockResolvedValue(run) mocks.ownedChat.mockResolvedValue(context) workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('read') - mocks.available.mockResolvedValue(true) + resetPermissionGroupScopeMock() mocks.claim.mockResolvedValue({ args: { action: 'list_apps' } }) }) it('returns canonical arguments only after current chat access and an atomic claim', async () => { @@ -81,11 +84,20 @@ describe('native computer authorization', () => { ).rejects.toThrow() expect(mothershipAsyncRunsMockFns.mockGetAsyncToolCall).not.toHaveBeenCalled() }) - it('rejects disabled rollout without claiming an action', async () => { - mocks.available.mockResolvedValue(false) - await expect(authorizeComputerUse.execute({ principal, input })).rejects.toThrow('unavailable') - expect(mocks.claim).not.toHaveBeenCalled() - }) + it.each(['disableComputerUse', 'hideCopilot'] as const)( + 'rejects a permission group with %s without claiming an action', + async (key) => { + mocks.ownedChat.mockResolvedValue({ ...context, workspaceOrganizationId: 'org-1' }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + [key]: true, + }) + await expect(authorizeComputerUse.execute({ principal, input })).rejects.toThrow( + 'Computer use is not available' + ) + expect(mocks.claim).not.toHaveBeenCalled() + } + ) it('rechecks current workspace membership', async () => { workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue(null) await expect(authorizeComputerUse.execute({ principal, input })).rejects.toThrow() diff --git a/apps/sim/lib/computer-use/application/authorize.ts b/apps/sim/lib/computer-use/application/authorize.ts index 5ba96e12911..20f0e0f1f0a 100644 --- a/apps/sim/lib/computer-use/application/authorize.ts +++ b/apps/sim/lib/computer-use/application/authorize.ts @@ -1,6 +1,5 @@ import type { SessionPrincipal } from '@sim/auth/principal' import { ComputerUseSchema } from '@sim/desktop-bridge/computer-use' -import { isComputerUseAvailable } from '@/lib/computer-use/availability.server' import { claimComputerUseTool } from '@/lib/computer-use/repository' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' @@ -19,13 +18,13 @@ export const authorizeComputerUse = defineAuthorizedChatUseCase({ id: 'desktop.computer.execute', minimumRole: 'read', workspaceApiKey: 'deny', - capability: 'copilot.use', + capability: 'computer_use.use', principalKinds: ['session'], }), organizationOperation: defineOrganizationOperation({ id: 'desktop.computer.execute', minimumRole: 'member', - capability: 'copilot.use', + capability: 'computer_use.use', principalKinds: ['session'], }), async resolveContext({ @@ -60,8 +59,6 @@ export const authorizeComputerUse = defineAuthorizedChatUseCase({ }, authorizationOptions: {}, async execute({ principal, context, input }) { - if (!(await isComputerUseAvailable(context.organizationId ?? context.workspaceOrganizationId))) - throw new OrchestrationError('forbidden', 'Computer use is unavailable on this deployment') const claimed = await claimComputerUseTool({ toolCallId: input.toolCallId, runId: context.runId, diff --git a/apps/sim/lib/computer-use/application/availability.ts b/apps/sim/lib/computer-use/application/availability.ts index 36e84c9c3a4..370c4c02d53 100644 --- a/apps/sim/lib/computer-use/application/availability.ts +++ b/apps/sim/lib/computer-use/application/availability.ts @@ -1,17 +1,14 @@ import type { Principal } from '@sim/auth/principal' -import type { ComputerUseOwner } from '@/lib/api/contracts/computer-use' -import { isComputerUseAvailable } from '@/lib/computer-use/availability.server' import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' import { defineAuthorizedOrganizationUseCase } from '@/lib/core/application/authorized-organization-use-case' -import { - assertOperationPrincipal, - defineOperation, - type OperationUseCase, -} from '@/lib/core/application/operation' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { + isOrganizationCapabilityWithheld, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' -/** permission-group-exempt: reports the rollout for a workspace the caller can read; actions require chat authorization. */ +/** permission-group-exempt: reports whether computer_use.use is withheld instead of refusing; actions declare it. */ const workspaceAvailabilityOperation = defineWorkspaceOperation({ id: 'desktop.computer.availability', minimumRole: 'read', @@ -20,7 +17,7 @@ const workspaceAvailabilityOperation = defineWorkspaceOperation({ principalKinds: ['session'], }) -/** permission-group-exempt: reports the rollout for an organization the caller belongs to; actions require chat authorization. */ +/** permission-group-exempt: reports whether computer_use.use is withheld instead of refusing; actions declare it. */ const organizationAvailabilityOperation = defineOrganizationOperation({ id: 'desktop.computer.availability', minimumRole: 'member', @@ -33,19 +30,29 @@ const workspaceAvailability = defineAuthorizedWorkspaceUseCase({ resolveContext: ({ input }: { input: { workspaceId: string } }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), authorizationOptions: {}, - execute: ({ context }) => isComputerUseAvailable(context.workspaceOrganizationId), + execute: async ({ principal, input, context }) => + // permission-group-enforced: computer_use.use — an availability read answers false rather than 403 + !(await isWorkspaceCapabilityWithheld( + principal.userId, + input.workspaceId, + 'computer_use.use', + context.workspaceOrganizationId + )), }) const organizationAvailability = defineAuthorizedOrganizationUseCase({ operation: organizationAvailabilityOperation, - execute: ({ input }: { input: { organizationId: string } }) => - isComputerUseAvailable(input.organizationId), + execute: async ({ input }: { input: { organizationId: string } }) => + // permission-group-enforced: computer_use.use — an availability read answers false rather than 403 + !(await isOrganizationCapabilityWithheld(input.organizationId, 'computer_use.use')), }) +/** The chat whose permission group applies: exactly one of a workspace or an organization. */ +type ComputerUseOwner = { workspaceId: string } | { organizationId: string } + /** - * Whether computer use is rolled out for a chat's owner: an organization chat follows its - * organization, a workspace chat its workspace's organization, and a personal workspace the - * global switch. + * Whether the chat owner's permission group allows computer use: an organization chat follows + * its organization's default group, a workspace chat the group governing that workspace. */ export function isComputerUseAvailableFor( principal: Principal, @@ -55,23 +62,3 @@ export function isComputerUseAvailableFor( ? organizationAvailability.execute({ principal, input: owner }) : workspaceAvailability.execute({ principal, input: owner }) } - -/** permission-group-exempt: dispatches to the owner's authorized rollout read above. */ -const availabilityOperation = defineOperation({ - id: 'desktop.computer.availability', - capability: 'none', - principalKinds: ['session'], -}) - -/** The desktop's rollout read before it touches the native helper; no device or app data leaves the desktop. */ -export const readComputerUseAvailability: OperationUseCase< - typeof availabilityOperation, - ComputerUseOwner, - { enabled: boolean } -> = { - operation: availabilityOperation, - async execute({ principal, input }) { - assertOperationPrincipal(principal, availabilityOperation) - return { enabled: await isComputerUseAvailableFor(principal, input) } - }, -} diff --git a/apps/sim/lib/computer-use/availability.server.ts b/apps/sim/lib/computer-use/availability.server.ts deleted file mode 100644 index c42a84fb23d..00000000000 --- a/apps/sim/lib/computer-use/availability.server.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' - -/** Computer use rolls out by organization; personal workspaces follow the global switch. */ -export function isComputerUseAvailable(orgId?: string | null): Promise { - return isFeatureEnabled('mothership-computer-use', orgId ? { orgId } : {}) -} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 99ce75e3603..b87c640130d 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -224,7 +224,7 @@ export const env = createEnv({ STRIPE_PRICE_TEAM_100_MO: z.string().min(1).optional(), // Team Max: $100/seat/mo STRIPE_PRICE_TEAM_100_YR: z.string().min(1).optional(), // Team Max: $1,020/seat/yr OVERAGE_THRESHOLD_DOLLARS: z.number().optional().default(100), // Dollar threshold for incremental overage billing (default: $100) - AWS_MARKETPLACE_PRODUCT_CODE: z.string().min(1).optional(), // AWS Marketplace SaaS product code; unset turns the Marketplace registration, claim, and reconcile surfaces off + AWS_MARKETPLACE_PRODUCTS: z.string().min(1).optional(), // AWS Marketplace SaaS products this deployment sells, as = pairs (lib/billing/aws-marketplace/products.ts); unset turns the Marketplace surfaces off AWS_MARKETPLACE_EVENTS_QUEUE_URL: z.string().url().optional(), // SQS queue receiving the Marketplace agreement EventBridge events the reconcile cron drains // Email & Communication @@ -564,6 +564,8 @@ export const env = createEnv({ ZOHO_CLIENT_SECRET: z.string().optional(), // Zoho OAuth client secret (Zoho Desk) WEALTHBOX_CLIENT_ID: z.string().optional(), // WealthBox OAuth client ID WEALTHBOX_CLIENT_SECRET: z.string().optional(), // WealthBox OAuth client secret + APOLLO_CLIENT_ID: z.string().optional(), + APOLLO_CLIENT_SECRET: z.string().optional(), PIPEDRIVE_CLIENT_ID: z.string().optional(), // Pipedrive OAuth client ID PIPEDRIVE_CLIENT_SECRET: z.string().optional(), // Pipedrive OAuth client secret LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID @@ -616,7 +618,6 @@ export const env = createEnv({ AGENTMAIL_DOMAIN: z.string().optional(), // Custom domain for AgentMail inboxes (default: agentmail.to) DASHBOARDS: z.boolean().optional(), PROJECTS: z.boolean().optional(), // Fallback for the `projects` feature flag off AppConfig - MSHIP_COMPUTER_USE: z.boolean().optional(), WORKFLOW_TESTS: z.boolean().optional(), // Fallback for the `workflow-tests` feature flag off AppConfig CHANGELOG: z.boolean().optional(), // Fallback for the `changelog` feature flag off AppConfig SIM_LINKS: z.boolean().optional(), // Fallback for the `sim-links` feature flag off AppConfig @@ -665,6 +666,7 @@ export const env = createEnv({ AGENT_MEMORY_HISTORY: z.boolean().optional(), CREDENTIAL_GROUPS: z.boolean().optional(), // Enable Credential Groups on self-hosted (bypasses hosted requirements) KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally + KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS: z.boolean().optional(), // Fallback for the `knowledge-connector-saved-credentials` feature flag off AppConfig // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 881de5a4fca..4a7295d4440 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -41,7 +41,6 @@ setEnv({ APPCONFIG_ENVIRONMENT: 'staging', DASHBOARDS: undefined, MSHIP_MODEL_SELECTOR: undefined, - MSHIP_COMPUTER_USE: undefined, AGENT_MEMORY_HISTORY: undefined, KNOWLEDGE_MEMBER_ACCESS: undefined, SLACK_SEARCH_SHARED_APP: undefined, @@ -64,16 +63,16 @@ const enabled = (flag: string, ctx?: FeatureFlagContext) => afterAll(resetEnvFlagsMock) describe('getFeatureFlags', () => { - it('gates computer use globally and defaults off without AppConfig', async () => { - withAppConfig({ 'mothership-computer-use': { enabled: true } }) - expect(await isFeatureEnabled('mothership-computer-use')).toBe(true) - withAppConfig({ 'mothership-computer-use': { enabled: false, userIds: ['user-1'] } }) - expect(await isFeatureEnabled('mothership-computer-use')).toBe(false) + it('gates a flag globally and defaults off without AppConfig', async () => { + withAppConfig({ dashboards: { enabled: true } }) + expect(await isFeatureEnabled('dashboards')).toBe(true) + withAppConfig({ dashboards: { enabled: false, userIds: ['user-1'] } }) + expect(await isFeatureEnabled('dashboards')).toBe(false) setEnvFlags({ isAppConfigEnabled: false }) - expect(await isFeatureEnabled('mothership-computer-use')).toBe(false) - envRef.MSHIP_COMPUTER_USE = true - expect(await isFeatureEnabled('mothership-computer-use')).toBe(true) - envRef.MSHIP_COMPUTER_USE = undefined + expect(await isFeatureEnabled('dashboards')).toBe(false) + envRef.DASHBOARDS = true + expect(await isFeatureEnabled('dashboards')).toBe(true) + envRef.DASHBOARDS = undefined }) beforeEach(() => { diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 5394541ae6e..f2c79eb2d10 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -51,12 +51,6 @@ const FEATURE_FLAGS = { 'Enable dashboard resources, rendering, analytics, and Mothership authoring. Supports global and organization rollout; disabled by default.', fallback: 'DASHBOARDS', }, - 'mothership-computer-use': { - description: - 'Enable native macOS computer use in Mothership. Supports global and organization rollout; ' + - 'personal workspaces follow the global switch. Each device must also opt in.', - fallback: 'MSHIP_COMPUTER_USE', - }, 'workflow-tests': { description: 'Enable workflow tests: the workspace Tests page, tests/.test.js files, and the Sim tests tool. Supports global and organization rollout; disabled by default.', @@ -114,6 +108,17 @@ const FEATURE_FLAGS = { 'Off-AppConfig falls back to PROJECTS.', fallback: 'PROJECTS', }, + 'knowledge-connector-saved-credentials': { + description: + 'Let a key-based knowledge connector that declares a saved-credential alternative (Amazon S3 ' + + 'with a saved AWS credential) be set up with that credential. Global on/off only; gates new ' + + 'setup and credential changes, never syncing of connectors already using a credential. Turn ' + + 'on only after the release that adds it is live on both the app and Trigger.dev workers. ' + + 'Once any connector uses a saved credential, rolling back to an earlier release is unsafe ' + + 'even with the flag off: older workers unschedule those connectors as credential-removed. ' + + 'Off-AppConfig falls back to KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS.', + fallback: 'KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS', + }, 'knowledge-member-access': { description: 'Organization Search (live) and the permission-aware workspace connector modes: members ' + diff --git a/apps/sim/lib/credentials/__integration__/database-connection.integration.ts b/apps/sim/lib/credentials/__integration__/database-connection.integration.ts new file mode 100644 index 00000000000..6a9964f4e55 --- /dev/null +++ b/apps/sim/lib/credentials/__integration__/database-connection.integration.ts @@ -0,0 +1,172 @@ +/** + * A saved PostgreSQL connection end to end against a real server: saving + * proves the login with SELECT 1 through the PostgreSQL tools' own guarded + * connection path, and a run through the registered tool operation reads the + * stored connection, bound to the run's workspace, whatever connection fields the + * call carries. + */ + +import { db } from '@sim/db' +import { credential, permissions, user, workspace } from '@sim/db/schema' +import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/posthog/server', () => posthogServerMock) + +import { env } from '@/lib/core/config/env' +import { createCredentialRecord } from '@/lib/credentials/orchestration/credential-create' +import { getInternalToolOperationHandler } from '@/lib/internal/tool-operations/registry.server' +import { POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' + +const databaseUrl = new URL(readTestDatabaseUrl()) +const connection = { + host: '127.0.0.1', + port: Number(databaseUrl.port || 5432), + database: decodeURIComponent(databaseUrl.pathname.slice(1)), + username: decodeURIComponent(databaseUrl.username), + // The disposable server trusts loopback logins, so any non-empty password is accepted. + password: decodeURIComponent(databaseUrl.password) || 'unused-under-trust-auth', + ssl: 'disabled', +} +const userId = generateId() +const workspaceId = generateId() +const otherWorkspaceId = generateId() + +function save(displayName: string, serviceAccountJson: string) { + return createCredentialRecord( + { + workspaceId, + type: 'service_account', + userId, + providerId: POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, + displayName, + serviceAccountJson, + }, + { authorizeWorkspace: true } + ) +} + +async function run(credentialId: string, runWorkspaceId: string) { + const handler = await getInternalToolOperationHandler('postgresql_query') + if (!handler) throw new Error('postgresql_query has no registered operation') + const result = await handler({ + toolId: 'postgresql_query', + input: { + host: 'unreachable.invalid', + port: 1, + database: 'other', + username: 'other', + password: 'inline', + ssl: 'required', + query: 'SELECT current_user AS who, current_database() AS db', + }, + headers: new Headers(), + context: { + workflowId: generateId(), + workspaceId: runWorkspaceId, + authorizedServiceAccountCredential: { credentialId, service: 'postgresql' }, + }, + requestId: generateId(), + }) + if (!(result instanceof Response)) throw new Error('postgresql_query returned a file') + return result +} + +describe('saved PostgreSQL connection', () => { + let credentialId: string + + beforeAll(async () => { + Object.assign(env, { EGRESS_ALLOWED_HOSTS: '127.0.0.1' }) + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Database connection fixture', + email: `${userId}@database-connection.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + for (const id of [workspaceId, otherWorkspaceId]) { + await insertWorkspaceFixture(db, { + id, + name: 'Database connection fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: id, + permissionType: 'admin', + }) + } + }, 60_000) + + afterAll(async () => { + await db.delete(credential).where(eq(credential.workspaceId, workspaceId)) + await deleteWorkspaceFixture(db, inArray(workspace.id, [workspaceId, otherWorkspaceId])) + await db.delete(user).where(eq(user.id, userId)) + }) + + it('saves a connection the server accepts', async () => { + const result = await save('Primary database', JSON.stringify(connection)) + + expect(result).toMatchObject({ success: true }) + const [row] = await db + .select() + .from(credential) + .where(eq(credential.displayName, 'Primary database')) + expect(row).toMatchObject({ + type: 'service_account', + providerId: POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, + workspaceId, + }) + expect(row.encryptedServiceAccountKey).not.toContain(connection.password) + credentialId = row.id + }) + + it('refuses to save a connection the server rejects', async () => { + const result = await save( + 'Missing database', + JSON.stringify({ ...connection, database: `${connection.database}_missing` }) + ) + + expect(result).toMatchObject({ success: false }) + const rows = await db + .select({ id: credential.id }) + .from(credential) + .where(eq(credential.displayName, 'Missing database')) + expect(rows).toHaveLength(0) + }) + + it('runs on the saved connection whatever connection fields the call carries', async () => { + const response = await run(credentialId, workspaceId) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + rows: [{ who: connection.username, db: connection.database }], + }) + }) + + it('refuses the connection to a run in another workspace', async () => { + const response = await run(credentialId, otherWorkspaceId) + + expect(response.status).toBe(403) + }) + + it('refuses a revoked connection', async () => { + await db + .update(credential) + .set({ revokedAt: new Date() }) + .where(eq(credential.id, credentialId)) + + const response = await run(credentialId, workspaceId) + + expect(response.status).toBe(403) + }) +}) diff --git a/apps/sim/lib/credentials/__integration__/saved-token-expiry.integration.ts b/apps/sim/lib/credentials/__integration__/saved-token-expiry.integration.ts new file mode 100644 index 00000000000..1310caa2248 --- /dev/null +++ b/apps/sim/lib/credentials/__integration__/saved-token-expiry.integration.ts @@ -0,0 +1,283 @@ +/** + * A saved token whose vendor states an expiry at verification records it on the credential row, + * the workspace credential list reports it, and replacing the token replaces (or clears) it, so a + * reconnected credential never keeps the previous token's expiry. A provider refusing a token past + * that expiry is explained as the expiry, with the provider's own failure kept underneath. + */ + +import { db } from '@sim/db' +import { credential, member, organization, permissions, user, workspace } from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { jsonResponse } from '@sim/testing/helpers/http' +import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/posthog/server', () => posthogServerMock) + +import { + toOrganizationCredential, + toWorkspaceCredential, +} from '@/lib/credentials/application/presentation' +import { updateCredentialRecord } from '@/lib/credentials/orchestration' +import { createCredentialRecord } from '@/lib/credentials/orchestration/credential-create' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { explainExpiredSavedToken } from '@/lib/credentials/saved-token-expiry' +import { GITHUB_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors' + +const userId = generateId() +const workspaceId = generateId() +const organizationId = generateId() +const originalFetch = globalThis.fetch +const DAY_MS = 24 * 60 * 60 * 1000 + +/** Expiry GitHub reports for each fixture token; a token without one never expires. */ +const tokenExpirations = new Map() + +/** GitHub's header form, whole seconds with a numeric offset, here UTC's. */ +function githubExpirationHeader(expiresAt: Date): string { + return `${expiresAt.toISOString().slice(0, 19).replace('T', ' ')} +0000` +} + +function expiringToken(daysAhead: number): { token: string; expiresAt: Date } { + const token = `github_pat_${generateId()}` + const expiresAt = new Date(Math.floor((Date.now() + daysAhead * DAY_MS) / 1000) * 1000) + tokenExpirations.set(token, expiresAt) + return { token, expiresAt } +} + +async function storedExpiry(credentialId: string): Promise { + const [row] = await db + .select({ expiresAt: credential.accessTokenExpiresAt }) + .from(credential) + .where(eq(credential.id, credentialId)) + return row?.expiresAt ?? null +} + +async function listedExpiry(credentialId: string): Promise { + const page = await listVisibleWorkspaceCredentials({ + workspaceId, + userId, + workspaceAccess: { canAdmin: true }, + }) + const row = page.data.find((entry) => entry.id === credentialId) + if (!row) throw new Error('Expected the credential in the workspace list') + return toWorkspaceCredential(row).expiresAt +} + +function create(displayName: string, apiToken: string) { + return createCredentialRecord( + { + workspaceId, + type: 'service_account', + userId, + providerId: GITHUB_SERVICE_ACCOUNT_PROVIDER_ID, + displayName, + apiToken, + }, + { authorizeWorkspace: true } + ) +} + +async function reconnect(credentialId: string, apiToken: string) { + const [row] = await db.select().from(credential).where(eq(credential.id, credentialId)) + return updateCredentialRecord({ credentialId, credential: row, actorUserId: userId, apiToken }) +} + +describe('saved token expiry', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Token expiry fixture', + email: `${userId}@token-expiry.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Token expiry fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + await db.insert(organization).values({ + id: organizationId, + name: 'Token expiry fixture', + slug: organizationId, + createdAt: now, + updatedAt: now, + }) + await db.insert(member).values({ + id: generateId(), + userId, + organizationId, + role: 'owner', + createdAt: now, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const target = typeof input === 'string' || input instanceof URL ? String(input) : input.url + if (target === 'https://api.github.com/user') { + const authorization = new Headers(init?.headers).get('Authorization') ?? '' + const expiresAt = tokenExpirations.get(authorization.replace(/^Bearer /, '')) + return jsonResponse( + { login: 'octo-bot', id: 1 }, + expiresAt + ? { + headers: { + 'GitHub-Authentication-Token-Expiration': githubExpirationHeader(expiresAt), + }, + } + : 200 + ) + } + return originalFetch(input, init) + }) + }, 60_000) + + afterAll(async () => { + vi.unstubAllGlobals() + await db.delete(credential).where(eq(credential.workspaceId, workspaceId)) + await db.delete(credential).where(eq(credential.organizationId, organizationId)) + await db.delete(organization).where(eq(organization.id, organizationId)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + }) + + it('records the expiry GitHub states for a new token and lists it', async () => { + const { token, expiresAt } = expiringToken(30) + + const created = await create('Expiring token', token) + + expect(created.success).toBe(true) + const credentialId = created.credential?.id ?? '' + expect(await storedExpiry(credentialId)).toEqual(expiresAt) + expect(await listedExpiry(credentialId)).toBe(expiresAt.toISOString()) + }) + + it('records no expiry for a token that never expires', async () => { + const created = await create('Lasting token', `ghp_${generateId()}`) + + const credentialId = created.credential?.id ?? '' + expect(await storedExpiry(credentialId)).toBeNull() + expect(await listedExpiry(credentialId)).toBeUndefined() + }) + + it('reports the expiry of an organization-owned token', async () => { + const { token, expiresAt } = expiringToken(30) + + const created = await createCredentialRecord( + { + organizationId, + type: 'service_account', + userId, + providerId: GITHUB_SERVICE_ACCOUNT_PROVIDER_ID, + displayName: 'Organization token', + apiToken: token, + }, + { authorizeWorkspace: false } + ) + + expect(created.success).toBe(true) + const [row] = await db + .select() + .from(credential) + .where(eq(credential.id, created.credential?.id ?? '')) + expect(toOrganizationCredential(row).expiresAt).toBe(expiresAt.toISOString()) + }) + + it('replaces the expiry when the token is replaced with another expiring token', async () => { + const first = expiringToken(5) + const created = await create('Rotated token', first.token) + const credentialId = created.credential?.id ?? '' + const second = expiringToken(90) + + expect((await reconnect(credentialId, second.token)).success).toBe(true) + + expect(await storedExpiry(credentialId)).toEqual(second.expiresAt) + }) + + it('clears the expiry when the replacement token never expires', async () => { + const created = await create('Made permanent', expiringToken(5).token) + const credentialId = created.credential?.id ?? '' + + expect((await reconnect(credentialId, `ghp_${generateId()}`)).success).toBe(true) + + expect(await storedExpiry(credentialId)).toBeNull() + expect(await listedExpiry(credentialId)).toBeUndefined() + }) + + describe('explaining a refused token', () => { + const refusal = { + status: 401, + statusText: 'Unauthorized', + data: { message: 'Bad credentials' }, + } + + async function savedCredential(expiresAt: Date | null) { + const created = await create(`Explained ${generateId()}`, `ghp_${generateId()}`) + const credentialId = created.credential?.id ?? '' + await db + .update(credential) + .set({ accessTokenExpiresAt: expiresAt }) + .where(eq(credential.id, credentialId)) + return credentialId + } + + it('names the expiry when the refused token is past it, keeping the provider failure', async () => { + const credentialId = await savedCredential(new Date('2026-01-15T00:00:00.000Z')) + + const explained = await explainExpiredSavedToken(credentialId, refusal) + + expect(explained?.message).toBe( + 'The saved GitHub personal access token expired on 2026-01-15 UTC. Create a new personal access token and reconnect the credential.' + ) + expect(explained).toMatchObject({ + status: 401, + statusText: 'Unauthorized', + data: { message: 'Bad credentials' }, + }) + }) + + it.each([ + ['an expiry still ahead', new Date(Date.now() + 30 * DAY_MS)], + ['no recorded expiry', null], + ])('leaves the refusal to stand for %s', async (_, expiresAt) => { + const credentialId = await savedCredential(expiresAt) + + expect(await explainExpiredSavedToken(credentialId, refusal)).toBeNull() + }) + + it('ignores the expiry of a credential that is not a saved service account', async () => { + const credentialId = generateId() + await db.insert(credential).values({ + id: credentialId, + workspaceId, + type: 'personal_token', + providerId: 'gitlab', + displayName: 'Personal GitLab token', + providerSubjectId: '1', + providerTenantId: 'https://gitlab.example.com', + encryptedPersonalToken: 'sealed', + grantedScopes: ['api'], + accessTokenExpiresAt: new Date('2026-01-15T00:00:00.000Z'), + createdBy: userId, + }) + + expect(await explainExpiredSavedToken(credentialId, refusal)).toBeNull() + }) + + it('leaves the refusal to stand for a credential that no longer exists', async () => { + expect(await explainExpiredSavedToken(generateId(), refusal)).toBeNull() + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts index 9d6f5799f2b..eedb4b1dabc 100644 --- a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts +++ b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts @@ -197,6 +197,65 @@ const STORED_MODES = [ }, ] as const +const CUSTOM_AUDIENCE = 'sim-123456789012' + +/** One stored keyless secret per provider, each carrying {@link CUSTOM_AUDIENCE}. */ +const CUSTOM_AUDIENCE_SECRETS = [ + { + provider: 'aws', + providerId: 'aws-service-account', + secret: { + type: 'aws_credential_v1', + config: { authType: 'oidc_role', roleArn, subject, audience: CUSTOM_AUDIENCE }, + }, + }, + { + provider: 'databricks', + providerId: 'databricks-service-account', + secret: { + type: 'databricks_credential_v1', + config: { + authType: 'oidc_federation', + host: databricksHost, + clientId, + subject, + audience: CUSTOM_AUDIENCE, + }, + }, + }, + { + provider: 'snowflake', + providerId: 'snowflake-service-account', + secret: { + ...keylessSecret, + config: { ...keylessSecret.config, audience: CUSTOM_AUDIENCE }, + }, + }, + { + provider: 'microsoft', + providerId: 'microsoft-app-service-account', + secret: { + type: 'microsoft_app_credential_v1', + config: { ...appConfig, authType: 'federated', subject, audience: CUSTOM_AUDIENCE }, + }, + }, + { + provider: 'google', + providerId: 'google-service-account', + secret: { + type: 'google_workload_identity_v1', + config: { + projectNumber: '123456789012', + poolId: 'sim-pool', + providerId: 'sim-oidc', + serviceAccountEmail: 'runner@analytics-prod.iam.gserviceaccount.com', + subject, + audience: CUSTOM_AUDIENCE, + }, + }, + }, +] as const + describe('preparing a reconnect', () => { beforeEach(() => { workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({ @@ -219,6 +278,25 @@ describe('preparing a reconnect', () => { } ) + it.each(CUSTOM_AUDIENCE_SECRETS)( + 'reopens a $provider keyless credential on its custom audience', + async ({ provider, providerId, secret }) => { + reconnecting(secret, { providerId }) + await expect(prepare(provider)).resolves.toMatchObject({ + available: true, + reconnectAudience: CUSTOM_AUDIENCE, + }) + } + ) + + it.each(STORED_MODES)( + 'reports no audience for a $provider credential stored as $expected', + async ({ provider, providerId, secret }) => { + reconnecting(secret, { providerId }) + expect(await prepare(provider)).not.toHaveProperty('reconnectAudience') + } + ) + it('reports the stored mode when this deployment cannot sign, so keyed modes still reopen', async () => { Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', undefined) try { @@ -235,11 +313,53 @@ describe('preparing a reconnect', () => { } }) + it('reports a national-cloud Microsoft credential’s cloud, so the dialog reopens on it', async () => { + reconnecting( + { + type: 'microsoft_app_credential_v1', + config: { ...appConfig, authType: 'client_secret', clientSecret: 'value', cloud: 'usgov' }, + }, + { providerId: 'microsoft-app-service-account' } + ) + await expect(prepare('microsoft')).resolves.toMatchObject({ + reconnectAuthType: 'client_secret', + reconnectCloud: 'usgov', + }) + }) + + it('reports no cloud for a global Microsoft credential or another provider', async () => { + reconnecting(STORED_MODES[9].secret, { providerId: 'microsoft-app-service-account' }) + expect(await prepare('microsoft')).not.toHaveProperty('reconnectCloud') + reconnecting(keylessSecret) + expect(await prepare()).not.toHaveProperty('reconnectCloud') + }) + it('gives Google no fixed audience', async () => { reconnecting(STORED_MODES[11].secret, { providerId: 'google-service-account' }) expect(await prepare('google')).not.toHaveProperty('audience') }) + it('reports a keyless Google credential’s delegation so a reconnect keeps it', async () => { + reconnecting( + { + type: 'google_workload_identity_v1', + config: { + projectNumber: '123456789012', + poolId: 'sim-pool', + providerId: 'sim-oidc', + serviceAccountEmail: 'runner@analytics-prod.iam.gserviceaccount.com', + subject, + delegation: { user: 'alice@example.com', services: ['gmail', 'google-calendar'] }, + }, + }, + { providerId: 'google-service-account' } + ) + await expect(prepare('google')).resolves.toMatchObject({ + reconnectAuthType: 'workload_identity', + reconnectDelegation: { user: 'alice@example.com', services: ['gmail', 'google-calendar'] }, + }) + }) + it('reveals nothing about a credential the caller does not administer', async () => { reconnecting(keylessSecret, { isAdmin: false }) expect(await prepare()).not.toHaveProperty('reconnectAuthType') diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.ts index acf280b864e..2cee3688fde 100644 --- a/apps/sim/lib/credentials/application/prepare-workload-identity.ts +++ b/apps/sim/lib/credentials/application/prepare-workload-identity.ts @@ -4,11 +4,15 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { credentialOperations } from '@/lib/credentials/application/operations' -import { storedAwsAuthType } from '@/lib/credentials/aws-service-account.server' -import { storedDatabricksAuthType } from '@/lib/credentials/databricks-service-account.server' -import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity' -import { storedMicrosoftAppAuthType } from '@/lib/credentials/microsoft-app-service-account.server' -import { storedSnowflakeAuthType } from '@/lib/credentials/snowflake-service-account.server' +import { storedAwsReconnectFields } from '@/lib/credentials/aws-service-account.server' +import { storedDatabricksReconnectFields } from '@/lib/credentials/databricks-service-account.server' +import { + type GoogleStoredDelegation, + storedGoogleReconnectFields, +} from '@/lib/credentials/google-workload-identity.server' +import { storedMicrosoftAppReconnectFields } from '@/lib/credentials/microsoft-app-service-account.server' +import type { MicrosoftCloudId } from '@/lib/credentials/microsoft-clouds' +import { storedSnowflakeReconnectFields } from '@/lib/credentials/snowflake-service-account.server' import { fixedWorkloadIdentityAudience, isWorkloadIdentityServiceAccountProviderId, @@ -30,13 +34,23 @@ export interface PrepareWorkloadIdentityInput { credentialId?: string } -/** The reconnected credential's stored mode; absent without one, or for anyone but its admin. */ -interface ReconnectAuthTypeField { - reconnectAuthType?: WorkloadIdentityProviderAuthType +/** + * What a reconnect reopens on, read from the stored secret: absent without a + * credential being reconnected, or for anyone but its admin. + */ +interface ReconnectFields

    { + /** The stored mode. */ + reconnectAuthType?: WorkloadIdentityProviderAuthType

    + /** A keyless Google credential's domain-wide delegation, so a reconnect keeps it. */ + reconnectDelegation?: GoogleStoredDelegation + /** A Microsoft app credential's national cloud; absent for a global one. */ + reconnectCloud?: MicrosoftCloudId + /** A keyless credential's custom audience; absent when it uses the default. */ + reconnectAudience?: string } export type PrepareWorkloadIdentityResult = - | ({ available: false } & ReconnectAuthTypeField) + | ({ available: false } & ReconnectFields) | ({ available: true issuer: string @@ -45,37 +59,59 @@ export type PrepareWorkloadIdentityResult = subject: string subjectProof: string expiresAt: string - } & ReconnectAuthTypeField) + } & ReconnectFields) + +/** What a provider's stored-secret reader returns: the non-secret fields a reconnect keeps. */ +interface StoredReconnectRead

    { + authType?: WorkloadIdentityProviderAuthType

    + audience?: string + cloud?: MicrosoftCloudId + delegation?: GoogleStoredDelegation +} + +function reconnectFrom

    ( + read: (decrypted: string) => StoredReconnectRead

    +): (decrypted: string) => ReconnectFields

    { + return (decrypted) => { + const { authType, audience, cloud, delegation } = read(decrypted) + return { + ...(authType ? { reconnectAuthType: authType } : {}), + ...(audience ? { reconnectAudience: audience } : {}), + ...(cloud ? { reconnectCloud: cloud } : {}), + ...(delegation ? { reconnectDelegation: delegation } : {}), + } + } +} /** * Every keyless provider also has key-based modes under the same provider ID, so a * reconnect reads the stored secret's mode and the dialog reopens on it rather than - * on a default that would drop the credential's role, secret, or certificate. + * on a default that would drop the credential's role, secret, or certificate. A + * provider whose mode has further non-secret settings a reconnect must keep (a + * Google credential's domain-wide delegation, a Microsoft app credential's national + * cloud, a keyless credential's custom audience) reads those here too. */ -const STORED_AUTH_TYPE: { - [P in WorkloadIdentityProvider]: ( - decrypted: string - ) => WorkloadIdentityProviderAuthType

    | undefined +const STORED_RECONNECT: { + [P in WorkloadIdentityProvider]: (decrypted: string) => ReconnectFields

    } = { - aws: storedAwsAuthType, - databricks: storedDatabricksAuthType, - snowflake: storedSnowflakeAuthType, - microsoft: storedMicrosoftAppAuthType, - google: (decrypted) => - isGoogleWorkloadIdentitySecret(decrypted) ? 'workload_identity' : 'json_key', + aws: reconnectFrom<'aws'>(storedAwsReconnectFields), + databricks: reconnectFrom<'databricks'>(storedDatabricksReconnectFields), + snowflake: reconnectFrom<'snowflake'>(storedSnowflakeReconnectFields), + microsoft: reconnectFrom<'microsoft'>(storedMicrosoftAppReconnectFields), + google: reconnectFrom<'google'>(storedGoogleReconnectFields), } /** - * The stored auth mode of a credential of this provider in this workspace, which - * this person administers. Only the mode leaves the stored secret, and only for - * someone allowed to reconnect the credential. + * What a reconnect of this provider's credential in this workspace, which this + * person administers, reopens on. Only these non-secret fields leave the stored + * secret, and only for someone allowed to reconnect the credential. */ -async function storedReconnectAuthType( +async function storedReconnectFields( provider: WorkloadIdentityProvider, credentialId: string, workspaceId: string, userId: string -): Promise { +): Promise { const access = await getCredentialActorContext(credentialId, userId, { workspaceId }) const row = access.credential if ( @@ -87,10 +123,10 @@ async function storedReconnectAuthType( workloadIdentityProviderFor(row.providerId) !== provider || !row.encryptedServiceAccountKey ) { - return undefined + return {} } const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) - return STORED_AUTH_TYPE[provider](decrypted) + return STORED_RECONNECT[provider](decrypted) } /** @@ -110,15 +146,14 @@ export const prepareWorkloadIdentity = defineAuthorizedWorkspaceUseCase({ }, authorizationOptions: {}, execute: async ({ principal, input }): Promise => { - const reconnectAuthType = input.credentialId - ? await storedReconnectAuthType( + const reconnect = input.credentialId + ? await storedReconnectFields( input.provider, input.credentialId, input.workspaceId, requirePrincipalSubjectUserId(principal) ) - : undefined - const reconnect = reconnectAuthType ? { reconnectAuthType } : {} + : {} // A deployment without a signing key offers only key- and secret-based credentials. if (!(await isWorkloadIdentityConfigured())) return { available: false, ...reconnect } const userId = requirePrincipalSubjectUserId(principal) diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts index 50a45db01dd..c652fa13bf3 100644 --- a/apps/sim/lib/credentials/application/presentation.ts +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -31,6 +31,15 @@ export function toV2Credential(row: PublicCredentialSource): V2Credential { } } +/** + * When a credential's saved token stops working. Only a service account's pasted token and a + * personal token keep their expiry on the row; an OAuth grant's tokens live on its account. + */ +function savedTokenExpiresAt(row: CredentialRow | VisibleWorkspaceCredential): Date | null { + if (row.type !== 'service_account' && row.type !== 'personal_token') return null + return row.accessTokenExpiresAt ?? null +} + /** Serializes credential metadata for the internal workspace surface. */ export function toWorkspaceCredential( row: CredentialRow | VisibleWorkspaceCredential, @@ -44,6 +53,7 @@ export function toWorkspaceCredential( ? 'admin' : (access?.member?.role ?? ('role' in row ? row.role : undefined)) const status = access?.member?.status ?? (access?.isAdmin ? 'active' : undefined) + const expiresAt = savedTokenExpiresAt(row) return { id: row.id, workspaceId: row.workspaceId, @@ -59,6 +69,7 @@ export function toWorkspaceCredential( ...(row.type === 'personal_token' && row.providerTenantId ? { instanceUrl: row.providerTenantId } : {}), + ...(expiresAt ? { expiresAt: expiresAt.toISOString() } : {}), createdBy: row.createdBy, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -77,6 +88,7 @@ export function toOrganizationCredential(row: CredentialRow): OrganizationCreden ) { throw new Error('Organization credential presentation requires an organization connection') } + const expiresAt = savedTokenExpiresAt(row) return { id: row.id, organizationId: row.organizationId, @@ -89,6 +101,7 @@ export function toOrganizationCredential(row: CredentialRow): OrganizationCreden accountId: row.accountId, envKey: null, envOwnerUserId: null, + ...(expiresAt ? { expiresAt: expiresAt.toISOString() } : {}), createdBy: row.createdBy, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), diff --git a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts index af9fef25fec..bcb7b3290c0 100644 --- a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts @@ -25,6 +25,7 @@ import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/creden import { env } from '@/lib/core/config/env' import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' +import { DATABASE_CONNECTION_DESCRIPTORS } from '@/lib/credentials/database-connections/descriptors' import { getMissingServiceAccountFields } from '@/lib/credentials/service-account-fields' import { isWorkloadIdentityServiceAccountProviderId, @@ -79,6 +80,24 @@ describe('v2 credential provider catalog contract', () => { ).toBe(false) }) + it('tells API callers the region an optional region field takes when omitted', async () => { + const providers = await listProviders() + const sentry = providers.find( + (provider) => + provider.type === 'service_account' && provider.providerId === 'sentry-service-account' + ) + + expect(sentry?.fields.find((field) => field.id === 'domain')).toMatchObject({ + required: false, + defaultValue: 'us', + options: [ + { value: 'us', label: 'US (sentry.io)' }, + { value: 'de', label: 'EU (de.sentry.io)' }, + ], + }) + expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true) + }) + it('keeps the field-less GitHub installation in the internal catalog for existing credentials', async () => { const catalog = await listCredentialProviderCatalog(createSessionPrincipal(), context) const installation = catalog.find( @@ -127,10 +146,12 @@ describe('v2 credential provider catalog contract', () => { const snowflake = keyless.find( (provider) => provider.providerId === 'snowflake-service-account' ) - expect(snowflake?.fields.find((field) => field.id === 'serviceAccountJson')).toMatchObject({ - required: false, - secret: true, - }) + const snowflakeConfiguration = snowflake?.fields.find( + (field) => field.id === 'serviceAccountJson' + ) + expect(snowflakeConfiguration).toMatchObject({ required: false, secret: true }) + expect(snowflakeConfiguration?.hint).toContain('"key_pair"') + expect(snowflakeConfiguration?.hint).toContain('Keyless') }) it('offers no keyless mode where this deployment cannot sign', async () => { @@ -151,7 +172,49 @@ describe('v2 credential provider catalog contract', () => { (provider) => provider.type === 'service_account' && provider.providerId === 'snowflake-service-account' ) - expect(snowflake?.fields.map((field) => field.id)).toEqual(['domain', 'apiToken']) + expect(snowflake?.fields.map((field) => field.id)).toEqual([ + 'domain', + 'apiToken', + 'serviceAccountJson', + ]) + const configuration = snowflake?.fields.find((field) => field.id === 'serviceAccountJson') + expect(configuration).toMatchObject({ required: false, secret: true, multiline: true }) + expect(configuration?.hint).toContain('"key_pair"') + expect(configuration?.hint).not.toContain('Keyless') + }) + + it('offers the Snowflake key pair to organization credentials, which have no keyless mode', async () => { + Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', SIGNING_KEY) + Reflect.set(env, 'WORKLOAD_IDENTITY_ISSUER', 'https://sim.example.com/oidc/workload') + const catalog = await listCredentialProviderCatalog(createSessionPrincipal(), { + organizationId: 'org-1', + }) + const snowflake = catalog.find( + (provider) => + provider.type === 'service_account' && provider.providerId === 'snowflake-service-account' + ) + + expect(snowflake?.type === 'service_account' && snowflake.keyless).toBeFalsy() + const configuration = snowflake?.fields.find((field) => field.id === 'serviceAccountJson') + expect(configuration?.hint).toContain('"key_pair"') + expect(configuration?.hint).not.toContain('Keyless') + }) + + it('tells headless clients the ServiceNow username belongs to the password sign-in only', async () => { + const providers = await listProviders('servicenow') + const servicenow = providers.find( + (provider) => + provider.type === 'service_account' && provider.providerId === 'servicenow-service-account' + ) + + expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true) + expect(servicenow?.fields.find((field) => field.id === 'providerWorkspaceId')).toMatchObject({ + required: true, + requiredForAuthMethods: ['token'], + }) + expect( + servicenow?.fields.find((field) => field.id === 'domain')?.requiredForAuthMethods + ).toBeUndefined() }) it('lists the native Claude Platform provider when filtered', async () => { @@ -180,4 +243,18 @@ describe('v2 credential provider catalog contract', () => { expect.objectContaining({ providerId: CLAUDE_PROVIDER_ID, available: false }) ) }) + + it('tells API callers what each saved database connection transport-security mode trades away', async () => { + const providers = await listProviders() + + for (const descriptor of Object.values(DATABASE_CONNECTION_DESCRIPTORS)) { + const provider = providers.find( + (entry) => entry.type === 'service_account' && entry.providerId === descriptor.providerId + ) + const hint = provider?.fields.find((field) => field.id === 'serviceAccountJson')?.hint + for (const field of descriptor.tls) { + expect(hint).toContain(field.hint) + } + } + }) }) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 4e00fb90985..1933f5089cc 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -5,7 +5,9 @@ import { CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, type ClientCredentialAccountField, } from '@/lib/credentials/client-credential-accounts/descriptors' +import { getDatabaseConnectionDescriptor } from '@/lib/credentials/database-connections/descriptors' import { + SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, type TokenServiceAccountField, } from '@/lib/credentials/token-service-accounts/descriptors' @@ -54,6 +56,8 @@ export interface CredentialProviderField { multiline: boolean requiredForAuthMethods?: string[] options?: CredentialProviderFieldOption[] + /** The value an optional field takes when it is omitted (a region's default). */ + defaultValue?: string hint?: string } @@ -119,13 +123,18 @@ function providerField( id: field.id, label: field.label, placeholder: field.placeholder, - required: !('optional' in field && field.optional), + required: + !('optional' in field && field.optional) && + !('defaultValue' in field && field.defaultValue !== undefined), secret: field.secret, multiline: 'multiline' in field && field.multiline === true, ...('requiredForAuthMethods' in field && field.requiredForAuthMethods ? { requiredForAuthMethods: [...field.requiredForAuthMethods] } : {}), ...('options' in field && field.options ? { options: [...field.options] } : {}), + ...('defaultValue' in field && field.defaultValue !== undefined + ? { defaultValue: field.defaultValue } + : {}), ...('hint' in field && field.hint ? { hint: field.hint } : 'hintMessage' in field && field.hintMessage @@ -157,8 +166,8 @@ function keylessModeFor(providerId: string): KeylessCatalogMode | undefined { /** * Adds a provider's keyless mode to its key-based descriptor, the same way for * every keyless provider: one description suffix, and a hint on the - * `serviceAccountJson` field — which a token provider such as Snowflake gains - * as an optional alternative to its token fields. + * `serviceAccountJson` field, which is added as an optional alternative to the + * other fields when the descriptor has none. */ function withKeylessMode( descriptor: ServiceAccountDescriptor, @@ -167,7 +176,7 @@ function withKeylessMode( if (!mode) return descriptor const { keyless, scope } = mode const scopeNote = scope ? ` ${scope}` : '' - const hint = `Keyless: send the keylessConfig from Create workload identity subject with ${keyless.requiredConfigFields.join(', ')} added.${scopeNote}` + const hint = `Keyless: send the keylessConfig from Create workload identity subject with ${keyless.requiredConfigFields.join(', ')} added. Optionally add audience to have Sim sign for your own value instead of the default audience (for Google Cloud, the workload identity provider's resource name), and trust that value.${scopeNote}` const configurationField = descriptor.fields.find((field) => field.id === 'serviceAccountJson') const fields = configurationField ? descriptor.fields.map((field) => @@ -269,6 +278,39 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript ], } } + const database = getDatabaseConnectionDescriptor(providerId) + if (database) { + const tls = database.tls + .map( + (field) => + `${field.id} (${field.options.map((option) => option.value).join(', ')}; default ${field.defaultValue})` + ) + .join(' and ') + const tlsTradeoffs = database.tls.map((field) => `${field.id}: ${field.hint}`).join(' ') + return { + name: `${database.label} connection`, + description: `Connect to ${database.label} with a saved host, database, login, and transport-security settings.`, + docsUrl: database.docsUrl, + fields: [ + { + id: 'serviceAccountJson', + label: 'Connection configuration', + placeholder: JSON.stringify({ + host: 'db.example.com', + port: database.defaultPort, + database: 'app', + username: database.usernamePlaceholder, + password: '...', + ...Object.fromEntries(database.tls.map((field) => [field.id, field.defaultValue])), + }), + required: true, + secret: true, + multiline: true, + hint: `JSON object with host, port, database, username, password, and optional ${tls}. Sim verifies it with SELECT 1 before saving. ${tlsTradeoffs}`, + }, + ], + } + } if (providerId === ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID) { return { name: 'Oracle Database connection', @@ -434,6 +476,29 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript } } + if (providerId === SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID) { + const tokenDescriptor = TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID] + return { + name: 'Snowflake connection', + description: 'Connect Snowflake with a programmatic access token or an RSA key pair.', + docsUrl: tokenDescriptor.docsUrl, + helpText: tokenDescriptor.helpText, + fields: [ + ...tokenDescriptor.fields.map(providerField), + { + id: 'serviceAccountJson', + label: 'Connection configuration', + placeholder: + '{"authType":"key_pair","host":"myorg-myaccount.snowflakecomputing.com","user":"SIM_SERVICE","privateKey":"-----BEGIN PRIVATE KEY-----..."}', + required: false, + secret: true, + multiline: true, + hint: 'Send instead of the other fields. Key pair: JSON object with authType: "key_pair", host, user, and privateKey (an unencrypted RSA private key as PEM, 2048 to 4096 bits, whose public key is set on the user).', + }, + ], + } + } + const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ providerId as keyof typeof TOKEN_SERVICE_ACCOUNT_DESCRIPTORS diff --git a/apps/sim/lib/credentials/aws-service-account.server.test.ts b/apps/sim/lib/credentials/aws-service-account.server.test.ts index 9479f69c189..f99f068f03d 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.test.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.test.ts @@ -136,6 +136,75 @@ describe('saving a keyless AWS role', () => { }) }) +describe('choosing the audience of a keyless AWS role', () => { + const CUSTOM_AUDIENCE = 'sim-123456789012' + + beforeEach(() => { + hoisted.send.mockReset() + hoisted.mint.mockClear() + encryptionMockFns.mockEncryptSecret.mockReset() + encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + roleTrusting(true) + }) + + function reconnectWith(audience?: string) { + return JSON.stringify({ + authType: 'oidc_role', + roleArn: ROLE_ARN, + ...(audience ? { audience } : {}), + }) + } + + function storedConfig() { + return JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0][0]).config + } + + it('verifies and probes the trust pin with a custom audience, and stores it', async () => { + await verifyAndEncryptAwsCredential(reconnectWith(CUSTOM_AUDIENCE), keylessReconnect.owner) + + expect(hoisted.mint.mock.calls.length).toBeGreaterThanOrEqual(2) + for (const [input] of hoisted.mint.mock.calls) { + expect(input).toMatchObject({ audience: CUSTOM_AUDIENCE }) + } + expect(hoisted.mint).toHaveBeenCalledWith(expect.objectContaining({ subject: SUBJECT })) + expect(hoisted.mint).toHaveBeenCalledWith( + expect.objectContaining({ subject: expect.not.stringMatching(SUBJECT) }) + ) + expect(storedConfig()).toMatchObject({ audience: CUSTOM_AUDIENCE }) + }) + + it.each([undefined, 'sts.amazonaws.com'])( + 'mints the default audience and stores no audience key when given %s', + async (audience) => { + await verifyAndEncryptAwsCredential(reconnectWith(audience), keylessReconnect.owner) + + expect(hoisted.mint).toHaveBeenCalled() + for (const [input] of hoisted.mint.mock.calls) { + expect(input).toMatchObject({ audience: 'sts.amazonaws.com' }) + } + expect(storedConfig()).not.toHaveProperty('audience') + } + ) + + it('refuses another cloud’s default audience before signing anything', async () => { + await expect( + verifyAndEncryptAwsCredential( + reconnectWith('api://AzureADTokenExchange'), + keylessReconnect.owner + ) + ).rejects.toThrow(/another cloud/) + expect(hoisted.mint).not.toHaveBeenCalled() + }) + + it('names the custom audience when the role refuses it', async () => { + hoisted.send.mockRejectedValue(accessDenied()) + + await expect( + verifyAndEncryptAwsCredential(reconnectWith(CUSTOM_AUDIENCE), keylessReconnect.owner) + ).rejects.toThrow(`audience ${CUSTOM_AUDIENCE}`) + }) +}) + describe('loading a saved AWS credential for a run', () => { beforeEach(() => resetDbChainMock()) @@ -194,4 +263,20 @@ describe("resolving a run's AWS session", () => { expect(hoisted.send.mock.calls[0][0].input.DurationSeconds).toBe(durationSeconds) } ) + + it.each([ + { stored: {}, audience: 'sts.amazonaws.com' }, + { stored: { audience: 'sim-123456789012' }, audience: 'sim-123456789012' }, + ])('mints a run token for the stored audience $audience', async ({ stored, audience }) => { + hoisted.mint.mockClear() + hoisted.send.mockResolvedValue({ Credentials: { ...session, Expiration: expiration } }) + + await resolveAwsSessionCredentials( + { authType: 'oidc_role', roleArn: ROLE_ARN, subject: SUBJECT, ...stored }, + owner, + options + ) + + expect(hoisted.mint).toHaveBeenCalledWith(expect.objectContaining({ audience })) + }) }) diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts index 6bb817a7edb..513bd7ab74b 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.ts @@ -12,7 +12,12 @@ import { z } from 'zod' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { failureClause, parseServiceAccountConfig } from '@/lib/credentials/service-account-config' -import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + WORKLOAD_IDENTITY_AUDIENCES, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, mintWorkloadIdentityToken, @@ -43,6 +48,8 @@ import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' * - `assume_role`: a key pair that assumes a role, optionally with an external ID. * - `oidc_role`: no stored key at all — Sim signs a short-lived OIDC token for * this credential's subject and exchanges it with STS for the customer's role. + * Its `aud` is the stored custom `audience`, else `sts.amazonaws.com`; either + * must be a client ID on the customer's IAM OIDC identity provider. */ const AWS_WORKLOAD_IDENTITY_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.aws @@ -85,6 +92,8 @@ const oidcRoleShape = { authType: z.literal('oidc_role'), roleArn: roleArnSchema, subject: z.string().min(1).max(512), + /** Absent for the default audience, so earlier releases' strict schemas still parse it. */ + audience: storedWorkloadIdentityAudienceSchema, ...commonShape, } @@ -108,6 +117,7 @@ const submittedConfigSchema = z.discriminatedUnion('authType', [ ...oidcRoleShape, subject: oidcRoleShape.subject.optional(), subjectProof: z.string().min(1).max(512).optional(), + audience: workloadIdentityAudienceSchema('aws').optional(), }) .strict(), ]) @@ -200,7 +210,7 @@ export async function resolveAwsSessionCredentials( } const webIdentityToken = await mintWorkloadIdentityToken({ subject: config.subject, - audience: AWS_WORKLOAD_IDENTITY_AUDIENCE, + audience: config.audience ?? AWS_WORKLOAD_IDENTITY_AUDIENCE, owner, }) /** @@ -225,13 +235,23 @@ export async function resolveAwsSessionCredentials( return toSessionCredentials(response.Credentials, durationSeconds) } -/** The auth mode a decrypted AWS secret holds, or undefined when it is not one. */ -export function storedAwsAuthType(decrypted: string): AwsCredentialConfig['authType'] | undefined { +/** + * A decrypted AWS secret's auth mode and, for a keyless role, its custom + * audience, for a reconnect to prefill. Empty when the secret is not one. + */ +export function storedAwsReconnectFields(decrypted: string): { + authType?: AwsCredentialConfig['authType'] + audience?: string +} { try { const parsed = storedSecretSchema.safeParse(JSON.parse(decrypted)) - return parsed.success ? parsed.data.config.authType : undefined + if (!parsed.success) return {} + const { config } = parsed.data + return config.authType === 'oidc_role' + ? { authType: config.authType, audience: config.audience } + : { authType: config.authType } } catch { - return undefined + return {} } } @@ -263,7 +283,7 @@ function verificationFailure(config: AwsCredentialConfig, error: unknown): Error const detail = failureClause(error) if (config.authType === 'oidc_role') { return new AwsCredentialError( - `Sim could not assume this role: ${detail}. Check that the role trusts the Sim OIDC provider with this exact subject and audience ${AWS_WORKLOAD_IDENTITY_AUDIENCE}.` + `Sim could not assume this role: ${detail}. Check that the role trusts the Sim OIDC provider with this exact subject and audience ${config.audience ?? AWS_WORKLOAD_IDENTITY_AUDIENCE}, and that the IAM identity provider lists that audience.` ) } if (config.authType === 'assume_role') { @@ -292,10 +312,11 @@ export async function verifyAndEncryptAwsCredential( if (!(await isWorkloadIdentityConfigured())) { throw new WorkloadIdentityNotConfiguredError('Use access keys.') } - const { subject, subjectProof, ...rest } = submitted + const { subject, subjectProof, audience, ...rest } = submitted config = { ...rest, subject: bindSubject(subject, subjectProof, readStoredSubject(owner.storedSecret), owner), + ...storedAudienceField(audience, AWS_WORKLOAD_IDENTITY_AUDIENCE), } } else { config = submitted diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts index 4a46a16d2bb..e7ff2e4c558 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts @@ -99,12 +99,18 @@ describe('mintBoxServiceAccountToken', () => { }) }) - it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'rate_limit_exceeded' }, 429)) + it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse( + { error: 'rate_limit_exceeded' }, + { status: 429, headers: { 'Retry-After': '7' } } + ) + ) await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, + retryAfterMs: 7_000, }) }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts index 58a17d48fc3..0cf59991317 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts @@ -14,6 +14,7 @@ import { readProviderErrorSnippet, requireClientSecret, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' const logger = createLogger('BoxServiceAccountMinter') @@ -176,10 +177,12 @@ export async function mintBoxServiceAccountToken( ...(hint ? { hint } : {}), }) } - throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { - step: 'box_token_mint', - body, - }) + throw new TokenServiceAccountValidationError( + 'provider_unavailable', + res.status, + { step: 'box_token_mint', body }, + { retryAfterMs: throttledRetryAfterMs(res) } + ) } const payload = await parseProviderJson(res, 'box_token_mint') diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts index 8a6b2bbf57a..795bdc4ab3f 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts @@ -84,6 +84,39 @@ describe('CrowdStrike API client token exchange', () => { await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) }) + it("carries the token endpoint's Retry-After on a throttled answer", async () => { + mockFetch.mockResolvedValue( + jsonResponse( + { errors: [{ code: 429, message: 'rate limited' }] }, + { status: 429, headers: { 'Retry-After': '7' } } + ) + ) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + retryAfterMs: 7_000, + }) + }) + + it("keeps a throttled answer's status and Retry-After when its error body cannot be read", async () => { + mockFetch.mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError('terminated')) + }, + }), + { status: 429, headers: { 'Retry-After': '7' } } + ) + ) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 429, + retryAfterMs: 7_000, + }) + }) + it("reports a redirect to the API client's own cloud as a wrong cloud and never follows it", async () => { mockFetch.mockResolvedValue( new Response(null, { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts index 6bf77c4c8fb..85474bf2121 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts @@ -82,7 +82,8 @@ export async function mintCrowdStrikeServiceAccountToken( throw new TokenServiceAccountValidationError( providerFailureCode(error.status), error.status, - { step: STEP, cloud } + { step: STEP, cloud }, + { retryAfterMs: error.retryAfterMs } ) } throw new TokenServiceAccountValidationError('provider_unavailable', 502, { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts index 51912c73c5a..ed68e865831 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts @@ -254,6 +254,17 @@ describe('mintDocuSignServiceAccountToken', () => { }) }) + it("carries the token endpoint's Retry-After on a throttled answer", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } }) + ) + + await expect(mintDocuSignServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + retryAfterMs: 7_000, + }) + }) + it('keeps the key, assertion, and access token out of every failure', async () => { const failures: unknown[] = [] mockFetch.mockResolvedValueOnce( diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.ts index 2a0aee7ee39..f7e7e300541 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.ts @@ -19,6 +19,7 @@ import { parseProviderJson, readProviderErrorSnippet, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' const TOKEN_STEP = 'docusign_token_mint' @@ -266,11 +267,12 @@ export async function mintDocuSignServiceAccountToken( { step: TOKEN_STEP, environment, body, ...(hint ? { hint } : {}) } ) } - throw new TokenServiceAccountValidationError('provider_unavailable', tokenRes.status, { - step: TOKEN_STEP, - environment, - body, - }) + throw new TokenServiceAccountValidationError( + 'provider_unavailable', + tokenRes.status, + { step: TOKEN_STEP, environment, body }, + { retryAfterMs: throttledRetryAfterMs(tokenRes) } + ) } const payload = await parseProviderJson(tokenRes, TOKEN_STEP) @@ -297,11 +299,12 @@ export async function mintDocuSignServiceAccountToken( ) if (!userinfoRes.ok) { const body = await readProviderErrorSnippet(userinfoRes) - throw new TokenServiceAccountValidationError('provider_unavailable', userinfoRes.status, { - step: USERINFO_STEP, - environment, - body, - }) + throw new TokenServiceAccountValidationError( + 'provider_unavailable', + userinfoRes.status, + { step: USERINFO_STEP, environment, body }, + { retryAfterMs: throttledRetryAfterMs(userinfoRes) } + ) } const userinfo = await parseProviderJson(userinfoRes, USERINFO_STEP) const accounts = (Array.isArray(userinfo.accounts) ? userinfo.accounts : []).filter( diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.test.ts new file mode 100644 index 00000000000..9266a12175c --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.test.ts @@ -0,0 +1,24 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintHelpScoutServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/helpscout' + +const FIELDS = { clientId: 'helpscout-client', clientSecret: 'helpscout-secret', orgId: '' } + +const mockFetch = vi.fn() + +describe('Help Scout client credential exchange', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it("carries the token endpoint's Retry-After on a throttled answer", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } }) + ) + + await expect( + mintHelpScoutServiceAccountToken(FIELDS, { skipIdentity: true }) + ).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, retryAfterMs: 7_000 }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.ts index 3ae63521c17..8b256d8dad7 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.ts @@ -11,6 +11,7 @@ import { isTransientProviderStatus, requireClientSecret, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' const MAX_HELPSCOUT_AUTH_RESPONSE_BYTES = 64 * 1024 @@ -28,7 +29,8 @@ async function readHelpScoutAuthResponse( ? 'invalid_credentials' : 'provider_unavailable', response.status, - { step } + { step }, + { retryAfterMs: throttledRetryAfterMs(response) } ) } diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts index 66592bf3efa..e7163b7dda9 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts @@ -30,4 +30,18 @@ describe('Jama credential validation', () => { ).rejects.toMatchObject({ code: expectedCode, status }) } ) + + it("carries the token endpoint's Retry-After on a throttled answer", async () => { + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValueOnce( + Response.json({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } }) + ) + + await expect( + mintJamaServiceAccountToken({ + orgId: 'https://example.jamacloud.com', + clientId: 'client-id', + clientSecret: 'client-secret', + }) + ).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, retryAfterMs: 7_000 }) + }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts index 22038795a26..463b9371dea 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts @@ -10,6 +10,7 @@ import { isTransientProviderStatus, requireClientSecret, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' import { normalizeJamaInstanceUrl } from '@/lib/jama/client' @@ -49,7 +50,9 @@ export async function mintJamaServiceAccountToken( !isTransientProviderStatus(response.status) ? 'invalid_credentials' : 'provider_unavailable', - response.status + response.status, + undefined, + { retryAfterMs: throttledRetryAfterMs(response) } ) } return toRecord(await response.json()) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.test.ts index d211bde2cf0..57ffd1443f7 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.test.ts @@ -148,6 +148,22 @@ describe('mintNetSuiteServiceAccountToken', () => { await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({ code, status }) }) + it("carries the token endpoint's Retry-After on a throttled answer", async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } }) + ) + ) + + await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + retryAfterMs: 7_000, + }) + }) + it('redacts credentials and a reflected assertion from provider error metadata', async () => { let assertion = '' vi.stubGlobal( diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.ts index d0737e1d68a..9e3738e5970 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.ts @@ -17,6 +17,7 @@ import { tenantPrincipal } from '@/lib/credentials/principal' import { isTransientProviderStatus, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' const TOKEN_PATH = '/services/rest/auth/oauth2/v1/token' @@ -141,10 +142,12 @@ async function exchangeNetSuiteToken( response.status >= 400 && response.status < 500 && !isTransientProviderStatus(response.status) ? 'invalid_credentials' : 'provider_unavailable' - throw new TokenServiceAccountValidationError(code, response.status, { - step: TOKEN_MINT_STEP, - body: sanitizeNetSuiteTokenError(body, fields, assertion), - }) + throw new TokenServiceAccountValidationError( + code, + response.status, + { step: TOKEN_MINT_STEP, body: sanitizeNetSuiteTokenError(body, fields, assertion) }, + { retryAfterMs: throttledRetryAfterMs(response) } + ) } let payload: NetSuiteTokenResponse diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.test.ts index f620f90fd59..4a32ae86b6a 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.test.ts @@ -207,6 +207,16 @@ describe('Ramp client credential exchange', () => { } ) + it("carries the token endpoint's Retry-After on a throttled answer", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } }) + ) + + await expect(mintRampServiceAccountToken(FIELDS, { skipIdentity: true })).rejects.toMatchObject( + { code: 'provider_unavailable', retryAfterMs: 7_000 } + ) + }) + it.each([400, 401, 403, 408, 429, 500])( 'classifies provider failure %s without retaining its secret-bearing body', async (status) => { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.ts index 65921857245..994c2b6bfdd 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.ts @@ -11,6 +11,7 @@ import { isTransientProviderStatus, requireClientSecret, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' import { getScopesForService } from '@/lib/oauth/utils' @@ -29,7 +30,8 @@ async function readRampAuthResponse( ? 'invalid_credentials' : 'provider_unavailable', response.status, - { step } + { step }, + { retryAfterMs: throttledRetryAfterMs(response) } ) } diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts index 186612668a7..912da47f22d 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts @@ -102,6 +102,92 @@ describe('SailPoint API client token exchange', () => { await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) }) + it("carries the token endpoint's Retry-After on an unavailable answer", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ error: 'unavailable' }, { status: 503, headers: { 'Retry-After': '7' } }) + ) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + retryAfterMs: 7_000, + }) + }) + + it('waits out one short throttle and mints on the retry', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '2' } }) + ) + .mockResolvedValueOnce(jsonResponse({ access_token: 'sp-access', expires_in: 749 })) + + const minted = mintSailPointServiceAccountToken(FIELDS) + await vi.advanceTimersByTimeAsync(1_999) + expect(mockFetch).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + + await expect(minted).resolves.toMatchObject({ accessToken: 'sp-access' }) + expect(mockFetch).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('retries a short throttle only once and returns the second answer with its Retry-After', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + mockFetch.mockImplementation(async () => + jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '1' } }) + ) + + const minted = mintSailPointServiceAccountToken(FIELDS) + const settled = expect(minted).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 429, + retryAfterMs: 1_000, + }) + await vi.advanceTimersByTimeAsync(10_000) + + await settled + expect(mockFetch).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('returns a long throttle with its Retry-After instead of retrying it', async () => { + mockFetch.mockImplementation(async () => + jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '120' } }) + ) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 429, + retryAfterMs: 120_000, + }) + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it("keeps a throttled answer's status and Retry-After when its error body cannot be read", async () => { + mockFetch.mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError('terminated')) + }, + }), + { status: 429, headers: { 'Retry-After': '120' } } + ) + ) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 429, + retryAfterMs: 120_000, + }) + }) + it('treats a network failure or refused redirect as a provider failure', async () => { mockFetch.mockRejectedValue(new TypeError('fetch failed: unexpected redirect')) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts index 9a8683ae38a..5691a10d1d0 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts @@ -18,8 +18,10 @@ import { } from '@/lib/internal/sailpoint/client' const STEP = 'sailpoint_token_mint' -/** Bounds the whole exchange, including its 429 backoff retries. */ +/** Bounds the whole exchange. */ const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000 +/** The longest throttle the mint waits out in place, leaving both requests well inside the exchange budget. */ +const MAX_IN_PLACE_THROTTLE_WAIT_MS = 5_000 /** * Mints a SailPoint Identity Security Cloud access token for an API client or @@ -32,6 +34,10 @@ const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000 * from `expires_in`; the credential cache re-mints before it lapses. * * The token response names no identity, so the connect-time check is the mint. + * A 429 asking for at most {@link MAX_IN_PLACE_THROTTLE_WAIT_MS} is retried once + * here, so a brief throttle does not fail a cold mint or a connect-time check. + * A longer one, or a second 429, is returned at once with its `Retry-After`, and + * the credential resolver replays it for that long (within its own bounds). * * @see https://developer.sailpoint.com/docs/api/authentication */ @@ -57,7 +63,8 @@ export async function mintSailPointServiceAccountToken( tokenUrl, fields.clientId, clientSecret, - options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout + options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout, + { maxThrottleRetries: 1, maxThrottleWaitMs: MAX_IN_PLACE_THROTTLE_WAIT_MS } ) } catch (error) { options?.signal?.throwIfAborted() @@ -68,7 +75,8 @@ export async function mintSailPointServiceAccountToken( { step: STEP, tenant: host, - } + }, + { retryAfterMs: error.retryAfterMs } ) } if (isHostNotFound(error)) { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts index d918dac7a6c..7516fe0cf35 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts @@ -130,12 +130,18 @@ describe('mintSalesforceServiceAccountToken', () => { }) }) - it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'rate_limit_exceeded' }, 429)) + it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse( + { error: 'rate_limit_exceeded' }, + { status: 429, headers: { 'Retry-After': '7' } } + ) + ) await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, + retryAfterMs: 7_000, }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts index 495dbb48310..87cc11ad645 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts @@ -21,6 +21,7 @@ import { providerFailureReason, readProviderErrorSnippet, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' /** @@ -463,11 +464,12 @@ export async function mintSalesforceServiceAccountToken( ...(hint ? { hint } : {}), }) } - throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { - step: TOKEN_MINT_STEP, - host, - body, - }) + throw new TokenServiceAccountValidationError( + 'provider_unavailable', + res.status, + { step: TOKEN_MINT_STEP, host, body }, + { retryAfterMs: throttledRetryAfterMs(res) } + ) } const payload = await parseProviderJson(res, TOKEN_MINT_STEP) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts index 00ccdccd258..a22b744cdd5 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts @@ -153,6 +153,17 @@ describe('Zendesk credential minting', () => { await expect(mintZendeskServiceAccountToken(CLIENT)).rejects.toMatchObject({ code }) }) + it("carries the token endpoint's Retry-After on a throttled answer", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } }) + ) + + await expect(mintZendeskServiceAccountToken(CLIENT)).rejects.toMatchObject({ + code: 'provider_unavailable', + retryAfterMs: 7_000, + }) + }) + it('refuses a token response without an access token', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ token_type: 'bearer' })) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts index ad7bae47c3a..062ad14b05d 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts @@ -18,6 +18,7 @@ import { providerFailureCode, readProviderErrorSnippet, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' import { zendeskApiTokenCredential } from '@/tools/zendesk/types' @@ -64,7 +65,8 @@ async function throwForZendeskResponse(response: Response, step: string): Promis throw new TokenServiceAccountValidationError( response.status === 404 ? 'site_not_found' : providerFailureCode(response.status), response.status, - { step, body } + { step, body }, + { retryAfterMs: throttledRetryAfterMs(response) } ) } diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts index a0d87d76ee7..a5342b83433 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts @@ -187,12 +187,15 @@ describe('mintZohoDeskServiceAccountToken', () => { }) }) - it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'too_many_requests' }, 429)) + it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: 'too_many_requests' }, { status: 429, headers: { 'Retry-After': '7' } }) + ) await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, + retryAfterMs: 7_000, }) }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts index 4113ce36120..6960d0583f4 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts @@ -20,6 +20,7 @@ import { readProviderErrorSnippet, requireClientSecret, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { tryDeriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist' @@ -199,12 +200,17 @@ export async function mintZohoDeskServiceAccountToken( if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) { throw invalidCredentials(res.status, soid, dataCenter.id, body, zohoBodyError(body)) } - throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { - step: STEP, - soid, - dataCenter: dataCenter.id, - body, - }) + throw new TokenServiceAccountValidationError( + 'provider_unavailable', + res.status, + { + step: STEP, + soid, + dataCenter: dataCenter.id, + body, + }, + { retryAfterMs: throttledRetryAfterMs(res) } + ) } const payload = await parseProviderJson(res, STEP) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts index 63cf63dd9a8..6421913c8d1 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts @@ -69,12 +69,18 @@ describe('mintZoomServiceAccountToken', () => { }) }) - it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'rate_limit_exceeded' }, 429)) + it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse( + { error: 'rate_limit_exceeded' }, + { status: 429, headers: { 'Retry-After': '7' } } + ) + ) await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, + retryAfterMs: 7_000, }) }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts index 4d86f95da32..89daf45c2a7 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts @@ -11,6 +11,7 @@ import { readProviderErrorSnippet, requireClientSecret, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' const ZOOM_TOKEN_URL = 'https://zoom.us/oauth/token' @@ -88,10 +89,12 @@ export async function mintZoomServiceAccountToken( ...(hint ? { hint } : {}), }) } - throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { - step: 'zoom_token_mint', - body, - }) + throw new TokenServiceAccountValidationError( + 'provider_unavailable', + res.status, + { step: 'zoom_token_mint', body }, + { retryAfterMs: throttledRetryAfterMs(res) } + ) } const payload = await parseProviderJson(res, 'zoom_token_mint') diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts index 446f1ec8a7e..ddd9baf0210 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts @@ -64,6 +64,17 @@ describe('ZoomInfo client credentials exchange', () => { await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) }) + it("carries the token endpoint's Retry-After on a throttled answer", async () => { + mockSecureFetchWithValidation.mockResolvedValue( + Response.json({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } }) + ) + + await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + retryAfterMs: 7_000, + }) + }) + it('treats a refused redirect or network failure as a provider failure', async () => { mockSecureFetchWithValidation.mockRejectedValue(new Error('Too many redirects (max: 0)')) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts index c7bda0f1c68..9b72f30d82c 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts @@ -56,7 +56,8 @@ export async function mintZoomInfoServiceAccountToken( throw new TokenServiceAccountValidationError( providerFailureCode(error.tokenStatus), error.tokenStatus, - { step: STEP } + { step: STEP }, + { retryAfterMs: error.retryAfterMs } ) } throw new TokenServiceAccountValidationError('provider_unavailable', 502, { diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts index 2ba5cd6f308..f015a7bf441 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts @@ -137,6 +137,25 @@ beforeAll(async () => { ) return } + if (parsed.client_id.endsWith('-throttled')) { + response.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '90' }) + response.end(JSON.stringify({ error: 'rate_limited' })) + return + } + if (parsed.client_id.endsWith('-rate-limited')) { + response.writeHead(429, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ error: 'rate_limited' })) + return + } + if (parsed.client_id.endsWith('-outage')) { + response.writeHead(502).end() + return + } + if (parsed.client_id.endsWith('-garbled')) { + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.end('upstream proxy error') + return + } await sleep(15) const identity = `${request.headers['x-provider-origin']}:${parsed.client_id}` const issued = (applications.get(identity)?.issued ?? 0) + 1 @@ -321,6 +340,69 @@ describe('Vanta application token lifecycle against shared storage', () => { expect(await isActive(next.accessToken)).toBe(true) }) + it('waits as long as a throttled Vanta asks before any worker asks again', async () => { + const input = fields('throttled') + const keys = previousBuildKeys(input, READ) + const count = tokenRequests + const before = Date.now() + + await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 429, + }) + await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ status: 429 }) + + expect(tokenRequests).toBe(count + 1) + const stored = await readStoredRow(keys.failureKey) + expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(90_000) + expect(stored.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(90_000) + }) + + it('waits at least a minute after a Vanta 429 that names no Retry-After', async () => { + const input = fields('rate-limited') + const keys = previousBuildKeys(input, READ) + const before = Date.now() + + await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ status: 429 }) + + const stored = await readStoredRow(keys.failureKey) + expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(60_000) + }) + + it('asks a failing Vanta for a token at most once per 15 seconds, within its 5 tokens a minute', async () => { + const input = fields('outage') + const keys = previousBuildKeys(input, READ) + const count = tokenRequests + const before = Date.now() + + await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + + expect(tokenRequests).toBe(count + 1) + const stored = await readStoredRow(keys.failureKey) + expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(15_000) + }) + + it('paces a Vanta that answers 200 with an unreadable body like any other failing exchange', async () => { + const input = fields('garbled') + const keys = previousBuildKeys(input, READ) + const count = tokenRequests + const before = Date.now() + + await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + + expect(tokenRequests).toBe(count + 1) + const stored = await readStoredRow(keys.failureKey) + expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(15_000) + }) + it('fails closed when coordination storage fails', async () => { const count = tokenRequests vi.spyOn(db, 'transaction').mockRejectedValueOnce(new Error('database unavailable')) diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts index 2d1bc48ec70..a3ea31abb02 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts @@ -1,5 +1,5 @@ import { isRecordLike } from '@sim/utils/object' -import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { PayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { VANTA_PERMISSION_OPTIONS } from '@/lib/credentials/client-credential-accounts/descriptors' import { invalidateCoordinatedClientToken, @@ -10,10 +10,16 @@ import { isTransientProviderStatus, requireClientSecret, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' const SCOPES = VANTA_PERMISSION_OPTIONS.map((option) => option.value) const EXPIRY_BUFFER_MS = 30_000 +/** + * Vanta answers 429 when an application mints more than 5 tokens a minute, and every token it + * issues revokes the previous one, so a failing exchange is retried at most every 15 s (4 a minute). + */ +const MIN_EXCHANGE_INTERVAL_MS = 15_000 const STEP = 'vanta_token_mint' export interface VantaClientTokenFields { @@ -91,14 +97,25 @@ async function exchangeToken( ? 'invalid_credentials' : 'provider_unavailable', response.status, - { step: STEP, reason: 'provider rejected token exchange' } + { step: STEP, reason: 'provider rejected token exchange' }, + { retryAfterMs: throttledRetryAfterMs(response) } + ) + } + let payload: unknown + try { + payload = await readResponseJsonWithLimit(response, { + maxBytes: 64 * 1024, + label: 'Vanta authentication response', + signal, + }) + } catch (error) { + signal?.throwIfAborted() + throw unavailable( + error instanceof SyntaxError || error instanceof PayloadSizeLimitError + ? 'provider returned an invalid token response' + : 'provider token response could not be read' ) } - const payload = await readResponseJsonWithLimit(response, { - maxBytes: 64 * 1024, - label: 'Vanta authentication response', - signal, - }) if ( !isRecordLike(payload) || typeof payload.access_token !== 'string' || @@ -150,6 +167,7 @@ export async function resolveVantaClientToken( step: STEP, signal: options.signal, minRemainingMs: EXPIRY_BUFFER_MS, + minExchangeIntervalMs: MIN_EXCHANGE_INTERVAL_MS, failureKeyQualifier: scope, accessTokenDigestTag: apiDomain, pinnedState: { diff --git a/apps/sim/lib/credentials/client-credentials-grant.test.ts b/apps/sim/lib/credentials/client-credentials-grant.test.ts index 9cbaf6432c1..ee8004a43e5 100644 --- a/apps/sim/lib/credentials/client-credentials-grant.test.ts +++ b/apps/sim/lib/credentials/client-credentials-grant.test.ts @@ -1,8 +1,18 @@ import { jsonResponse } from '@sim/testing/helpers/http' +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { exchangeClientCredentialsGrant } from '@/lib/credentials/client-credentials-grant' +import { EgressRefusedError } from '@/lib/core/network/transport.server' +import { + exchangeClientCredentialsGrant, + postToConfiguredTokenEndpoint, +} from '@/lib/credentials/client-credentials-grant' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + const mockFetch = vi.fn() const REQUEST = { @@ -140,7 +150,11 @@ describe('exchangeClientCredentialsGrant', () => { await expect( exchangeClientCredentialsGrant({ ...REQUEST, classifyRefusal }) ).rejects.toMatchObject({ code: 'grant_not_permitted', status: 400 }) - expect(classifyRefusal).toHaveBeenCalledWith(400, expect.stringContaining('org_mismatch')) + expect(classifyRefusal).toHaveBeenCalledWith( + 400, + expect.stringContaining('org_mismatch'), + undefined + ) }) it.each([ @@ -158,6 +172,30 @@ describe('exchangeClientCredentialsGrant', () => { }) }) + it('carries the Retry-After of a throttled exchange, also to a provider classification', async () => { + mockFetch.mockImplementation(async () => + jsonResponse({ error: 'slow_down' }, { status: 429, headers: { 'Retry-After': '45' } }) + ) + + await expect(exchangeClientCredentialsGrant(REQUEST)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 429, + retryAfterMs: 45_000, + }) + const classifyRefusal = vi.fn( + (status: number, _body: string, retryAfterMs?: number) => + new TokenServiceAccountValidationError( + 'provider_unavailable', + status, + { step: REQUEST.step }, + { retryAfterMs } + ) + ) + await expect( + exchangeClientCredentialsGrant({ ...REQUEST, classifyRefusal }) + ).rejects.toMatchObject({ retryAfterMs: 45_000 }) + }) + it('refuses a token response that is not JSON', async () => { mockFetch.mockResolvedValue(new Response('gateway', { status: 200 })) @@ -166,3 +204,55 @@ describe('exchangeClientCredentialsGrant', () => { }) }) }) + +describe('postToConfiguredTokenEndpoint', () => { + const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation + const CONFIGURED = { + url: 'https://instance.example.com/oauth/token', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=client_credentials', + step: 'configured_token_mint', + } + + beforeEach(() => { + secureFetch.mockReset() + }) + + it('reports a host the egress policy refuses as the typed host, not an outage', async () => { + secureFetch.mockRejectedValueOnce(new EgressRefusedError('private address')) + + await expect(postToConfiguredTokenEndpoint(CONFIGURED)).rejects.toMatchObject({ + code: 'host_not_allowed', + status: 400, + }) + }) + + it.each([ + ['ENOTFOUND', 'site_not_found', 400], + ['EAI_AGAIN', 'provider_unavailable', 502], + ])('classifies a %s lookup failure as %s', async (resolverCode, code, status) => { + secureFetch.mockRejectedValueOnce( + new Error('url hostname could not be resolved', { + cause: Object.assign(new Error('lookup failed'), { code: resolverCode }), + }) + ) + + await expect(postToConfiguredTokenEndpoint(CONFIGURED)).rejects.toMatchObject({ + code, + status, + }) + }) + + it('surfaces a caller abort as the abort', async () => { + const controller = new AbortController() + secureFetch.mockImplementationOnce(async () => { + controller.abort() + throw new Error('request aborted') + }) + + const posting = postToConfiguredTokenEndpoint({ ...CONFIGURED, signal: controller.signal }) + + await expect(posting).rejects.toMatchObject({ name: 'AbortError' }) + await expect(posting).rejects.not.toBeInstanceOf(TokenServiceAccountValidationError) + }) +}) diff --git a/apps/sim/lib/credentials/client-credentials-grant.ts b/apps/sim/lib/credentials/client-credentials-grant.ts index 93522121d78..1b408da81dd 100644 --- a/apps/sim/lib/credentials/client-credentials-grant.ts +++ b/apps/sim/lib/credentials/client-credentials-grant.ts @@ -1,20 +1,27 @@ import { isRecordLike } from '@sim/utils/object' +import { EgressRefusedError } from '@/lib/core/network/transport.server' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { PayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { type FetchProviderOptions, fetchProvider, + isHostNotFound, + type ProviderResponse, providerFailureCode, providerFailureReason, readProviderErrorSnippet, TokenServiceAccountValidationError, + throttledRetryAfterMs, } from '@/lib/credentials/token-service-accounts/errors' const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024 +/** The guarded fetch refuses a larger declared body outright; JSON reads stay capped at {@link MAX_TOKEN_RESPONSE_BYTES}. */ +const MAX_CONFIGURED_ENDPOINT_RESPONSE_BYTES = 1024 * 1024 +const CONFIGURED_ENDPOINT_TIMEOUT_MS = 10_000 /** Form fields the grant itself sets, which an extra field may never replace. */ const RESERVED_GRANT_FIELDS = ['grant_type', 'client_id', 'client_secret'] as const -/** One OAuth 2.0 client-credentials token request to a fixed or validated token endpoint. */ -export interface ClientCredentialsGrantRequest { +interface ClientCredentialsGrantFields { /** The token endpoint, from a constant or a host the caller already validated. */ url: string clientId: string @@ -30,21 +37,38 @@ export interface ClientCredentialsGrantRequest { label: string /** The longest a token is kept, whatever `expires_in` the provider reports. */ maxLifetimeSeconds: number - fetchOptions?: FetchProviderOptions /** * Maps a refused exchange to a provider-specific error. Without it, a refusal is * `invalid_credentials` for a non-transient 4xx and `provider_unavailable` otherwise. + * `retryAfterMs` is the refusal's `Retry-After` (see {@link throttledRetryAfterMs}), which an + * error the provider would answer differently later should carry. */ - classifyRefusal?: (status: number, body: string) => TokenServiceAccountValidationError + classifyRefusal?: ( + status: number, + body: string, + retryAfterMs: number | undefined + ) => TokenServiceAccountValidationError signal?: AbortSignal } +/** + * One OAuth 2.0 client-credentials token request to a fixed or validated token endpoint. With + * `egress: 'configuredEndpoint'`, for a URL on a host the admin configured rather than a fixed + * provider host, the request goes through {@link postToConfiguredTokenEndpoint}, which takes no + * `fetchOptions`. + */ +export type ClientCredentialsGrantRequest = ClientCredentialsGrantFields & + ( + | { egress?: undefined; fetchOptions?: FetchProviderOptions } + | { egress: 'configuredEndpoint'; fetchOptions?: never } + ) + /** * Exchanges client credentials for an access token: a form POST of `grant_type=client_credentials` * with the client ID and secret in the body, redirects refused so the secret reaches no other host. * A response without a non-blank `access_token` and a positive integer `expires_in` is * `provider_unavailable`, and the token expires `min(expires_in, maxLifetimeSeconds)` after the - * request started. + * request started. A throttled or unavailable refusal carries its `Retry-After` as `retryAfterMs`. * * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 */ @@ -56,42 +80,92 @@ export async function exchangeClientCredentialsGrant( const replaced = RESERVED_GRANT_FIELDS.find((field) => Object.hasOwn(extraFields, field)) if (replaced) throw new Error(`An extra field cannot replace the grant's ${replaced}`) const startedAt = Date.now() - const response = await fetchProvider( - request.url, - { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'client_credentials', - client_id: request.clientId, - client_secret: request.clientSecret, - ...extraFields, - }).toString(), - cache: 'no-store', - signal, - }, + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + } + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: request.clientId, + client_secret: request.clientSecret, + ...extraFields, + }).toString() + const response: ProviderResponse = + request.egress === 'configuredEndpoint' + ? await postToConfiguredTokenEndpoint({ url: request.url, headers, body, step, signal }) + : await fetchProvider( + request.url, + { method: 'POST', headers, body, cache: 'no-store', signal }, + step, + request.fetchOptions + ) + return readAccessTokenResponse(response, { step, - request.fetchOptions - ) + label: request.label, + startedAt, + maxLifetimeSeconds: request.maxLifetimeSeconds, + classifyRefusal: request.classifyRefusal, + signal, + }) +} + +/** How {@link readAccessTokenResponse} reads one provider's token response. */ +interface AccessTokenResponseOptions { + /** The validation step named in errors and logs. */ + step: string + /** Names the provider's token response in a body-limit error. */ + label: string + /** When the token request was sent; the token's lifetime counts from here. */ + startedAt: number + /** The longest a token is kept, whatever lifetime the provider reports. */ + maxLifetimeSeconds: number + /** The field holding the token, `access_token` unless the provider names it otherwise. */ + tokenField?: string + /** The field holding the lifetime in seconds, `expires_in` unless the provider names it otherwise. */ + expiresInField?: string + /** + * Whether a lifetime of 0 means the provider set no expiry, so the token is kept for + * {@link maxLifetimeSeconds}. Otherwise a lifetime of 0 is an invalid response. + */ + zeroLifetimeMeansNoExpiry?: boolean + /** + * Maps a refusal to a provider-specific error, as the grant's own `classifyRefusal` does. + * Without it, a refusal is `invalid_credentials` for a non-transient 4xx and + * `provider_unavailable` otherwise. + */ + classifyRefusal?: ClientCredentialsGrantFields['classifyRefusal'] + signal?: AbortSignal +} + +/** + * Reads a token endpoint's response: a refusal is classified from its status, and a response + * without a non-blank token and a non-negative integer lifetime (positive unless + * {@link AccessTokenResponseOptions.zeroLifetimeMeansNoExpiry}) is `provider_unavailable`. The + * token expires `min(lifetime, maxLifetimeSeconds)` after the request started. + */ +export async function readAccessTokenResponse( + response: ProviderResponse, + options: AccessTokenResponseOptions +): Promise<{ accessToken: string; expiresAt: Date }> { + const { step, signal } = options if (!response.ok) { const body = await readProviderErrorSnippet(response) signal?.throwIfAborted() - throw request.classifyRefusal - ? request.classifyRefusal(response.status, body) + const retryAfterMs = throttledRetryAfterMs(response) + throw options.classifyRefusal + ? options.classifyRefusal(response.status, body, retryAfterMs) : new TokenServiceAccountValidationError( providerFailureCode(response.status), response.status, - { step, body } + { step, body }, + { retryAfterMs } ) } let payload: unknown try { payload = await readResponseJsonWithLimit(response, { maxBytes: MAX_TOKEN_RESPONSE_BYTES, - label: request.label, + label: options.label, signal, }) } catch (error) { @@ -104,22 +178,78 @@ export async function exchangeClientCredentialsGrant( } payload = undefined } + const token = isRecordLike(payload) ? payload[options.tokenField ?? 'access_token'] : undefined + const lifetime = isRecordLike(payload) + ? payload[options.expiresInField ?? 'expires_in'] + : undefined + const minimumLifetime = options.zeroLifetimeMeansNoExpiry ? 0 : 1 if ( - !isRecordLike(payload) || - typeof payload.access_token !== 'string' || - !payload.access_token.trim() || - typeof payload.expires_in !== 'number' || - !Number.isSafeInteger(payload.expires_in) || - payload.expires_in <= 0 + typeof token !== 'string' || + !token.trim() || + typeof lifetime !== 'number' || + !Number.isSafeInteger(lifetime) || + lifetime < minimumLifetime ) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step, reason: 'provider returned an invalid token response', }) } - const lifetimeSeconds = Math.min(payload.expires_in, request.maxLifetimeSeconds) + const lifetimeSeconds = + lifetime === 0 ? options.maxLifetimeSeconds : Math.min(lifetime, options.maxLifetimeSeconds) return { - accessToken: payload.access_token, - expiresAt: new Date(startedAt + lifetimeSeconds * 1000), + accessToken: token, + expiresAt: new Date(options.startedAt + lifetimeSeconds * 1000), + } +} + +/** A token request to an endpoint on a host the admin configured (a self-hosted or per-tenant instance). */ +export interface ConfiguredTokenEndpointRequest { + url: string + headers: Record + body: string + /** The validation step named in errors and logs. */ + step: string + signal?: AbortSignal +} + +/** + * Posts a token request to a host taken from a validated credential field rather than a constant, + * through the `configuredEndpoint` egress guard: private and loopback addresses are refused and the + * connection is pinned to the checked address, redirects are refused so the secret reaches no other + * host, and the response is size-capped. A host the policy refuses is `host_not_allowed`, one that + * does not resolve is `site_not_found`, and any other network failure is `provider_unavailable`; + * the caller classifies the response itself. + */ +export async function postToConfiguredTokenEndpoint( + request: ConfiguredTokenEndpointRequest +): Promise }> { + const { step, signal } = request + try { + return await secureFetchWithValidation(request.url, { + profile: 'configuredEndpoint', + method: 'POST', + headers: request.headers, + body: request.body, + signal, + timeout: CONFIGURED_ENDPOINT_TIMEOUT_MS, + maxRedirects: 0, + maxResponseBytes: MAX_CONFIGURED_ENDPOINT_RESPONSE_BYTES, + logUrlValidationDetails: false, + }) + } catch (error) { + signal?.throwIfAborted() + if (error instanceof EgressRefusedError) { + throw new TokenServiceAccountValidationError('host_not_allowed', 400, { + step, + reason: 'token endpoint host not allowed', + }) + } + const notFound = isHostNotFound(error) + throw new TokenServiceAccountValidationError( + notFound ? 'site_not_found' : 'provider_unavailable', + notFound ? 400 : 502, + { step, reason: notFound ? 'host does not resolve' : 'network error reaching provider' } + ) } } diff --git a/apps/sim/lib/credentials/coordinated-client-token.integration.ts b/apps/sim/lib/credentials/coordinated-client-token.integration.ts index 714466025d9..b2454165b52 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.integration.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.integration.ts @@ -1,11 +1,13 @@ import { createHash } from 'node:crypto' import { db } from '@sim/db' import { clientCredentialToken } from '@sim/db/schema' +import { createDeferred } from '@sim/testing/helpers/deferred' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { eq, like, or } from 'drizzle-orm' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' -import { decryptSecret } from '@/lib/core/security/encryption' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { type CoordinatedClientTokenInput, invalidateCoordinatedClientToken, @@ -13,6 +15,7 @@ import { } from '@/lib/credentials/coordinated-client-token' import { verifyAndBuildServiceAccountSecret } from '@/lib/credentials/service-account-secret' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' const NAMESPACE = `fixture${generateId().replaceAll('-', '')}` const ownedKeyPrefixes = new Set([`${NAMESPACE}:`]) @@ -63,6 +66,68 @@ function rejecting() { }) } +function failing(status: number, retryAfterMs?: number) { + return vi.fn(async (): Promise<{ accessToken: string; expiresAt: Date }> => { + throw new TokenServiceAccountValidationError( + 'provider_unavailable', + status, + { step: 'mint' }, + { + retryAfterMs, + } + ) + }) +} + +/** An exchange that issues one token valid for `lifetimeMs`, then fails with `then`. */ +function issuingOnce( + application: string, + lifetimeMs: number, + then: CoordinatedClientTokenInput['exchange'] +) { + return vi + .fn() + .mockResolvedValueOnce({ + accessToken: `${application}:token-1`, + expiresAt: new Date(Date.now() + lifetimeMs), + }) + .mockImplementation(then) +} + +async function failureRowOf(application: string) { + const rows = await db + .select() + .from(clientCredentialToken) + .where(like(clientCredentialToken.id, `${applicationKey(NAMESPACE, [application])}:%`)) + if (rows.length !== 1) throw new Error(`Expected one failure row, found ${rows.length}`) + return readStoredRow(rows[0].id) +} + +async function expireFailureRowsOf(application: string) { + await db + .update(clientCredentialToken) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(like(clientCredentialToken.id, `${applicationKey(NAMESPACE, [application])}:%`)) +} + +/** The previous build's failure decoders, which must keep accepting every failure this build stores. */ +const previousBuildDecodesFailure = { + coordinated: (value: unknown) => + isRecordLike(value) && + typeof value.code === 'string' && + [ + 'invalid_credentials', + 'grant_not_permitted', + 'site_not_found', + 'provider_unavailable', + ].includes(value.code) && + typeof value.status === 'number', + vantaBeforeCoordination: (value: unknown) => + isRecordLike(value) && + (value.code === 'invalid_credentials' || value.code === 'provider_unavailable') && + typeof value.status === 'number', +} + afterEach(() => { vi.unstubAllGlobals() }) @@ -118,9 +183,11 @@ describe('coordinated client-credentials token against shared storage', () => { await expect( resolveCoordinatedClientToken(input('memo', exchange, 'revoked')) ).rejects.toMatchObject({ code: 'invalid_credentials' }) - await expect( - resolveCoordinatedClientToken(input('memo', exchange, 'revoked')) - ).rejects.toMatchObject({ code: 'invalid_credentials' }) + const replayed = await resolveCoordinatedClientToken(input('memo', exchange, 'revoked')).catch( + (error: unknown) => error + ) + expect(replayed).toMatchObject({ code: 'invalid_credentials', replayForMs: expect.any(Number) }) + expect(replayed).not.toHaveProperty('retryAfterMs', expect.anything()) expect(exchange).toHaveBeenCalledOnce() await db @@ -204,27 +271,383 @@ describe('coordinated client-credentials token against shared storage', () => { ).resolves.toMatchObject({ accessToken: 'fresh-rejection:token-2' }) }) - it('sweeps expired failure memos', async () => { + it('sweeps failure memos once they can no longer extend a backoff', async () => { + for (const application of ['swept', 'retained']) { + await expect( + resolveCoordinatedClientToken(input(application, rejecting(), 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + } + const failurePrefix = (application: string) => `${applicationKey(NAMESPACE, [application])}:` + const expire = (application: string, agoMs: number) => + db + .update(clientCredentialToken) + .set({ expiresAt: new Date(Date.now() - agoMs) }) + .where(like(clientCredentialToken.id, `${failurePrefix(application)}%`)) + await expire('swept', 6 * 60_000) + await expire('retained', 60_000) + + await resolveCoordinatedClientToken(input('sweeper', provider('sweeper').exchange)) + + const remaining = async (application: string) => + db + .select({ id: clientCredentialToken.id }) + .from(clientCredentialToken) + .where(like(clientCredentialToken.id, `${failurePrefix(application)}%`)) + expect(await remaining('swept')).toEqual([]) + expect(await remaining('retained')).toHaveLength(1) + }) +}) + +describe('coordinated token resolution while the provider fails', () => { + it('serves the stored token while it is still valid when its early refresh is throttled', async () => { + const exchange = issuingOnce('throttled-refresh', 4 * 60_000, failing(429)) + await resolveCoordinatedClientToken(input('throttled-refresh', exchange)) + + for (let call = 0; call < 3; call++) { + await expect( + resolveCoordinatedClientToken(input('throttled-refresh', exchange)) + ).resolves.toMatchObject({ accessToken: 'throttled-refresh:token-1' }) + } + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it('reports the failed refresh once the stored token has run out', async () => { + const exchange = issuingOnce('expired-refresh', 20_000, failing(429)) + await resolveCoordinatedClientToken(input('expired-refresh', exchange)) + await expect( - resolveCoordinatedClientToken(input('swept', rejecting(), 'revoked')) + resolveCoordinatedClientToken(input('expired-refresh', exchange)) + ).rejects.toMatchObject({ code: 'provider_unavailable', status: 429 }) + }) + + it('stops serving the stored token once the provider refuses the credentials', async () => { + const exchange = issuingOnce('refused-refresh', 4 * 60_000, rejecting()) + await resolveCoordinatedClientToken(input('refused-refresh', exchange)) + + await expect( + resolveCoordinatedClientToken(input('refused-refresh', exchange)) ).rejects.toMatchObject({ code: 'invalid_credentials' }) - const failurePrefix = `${applicationKey(NAMESPACE, ['swept'])}:` - await db - .update(clientCredentialToken) - .set({ expiresAt: new Date(Date.now() - 1000) }) - .where(like(clientCredentialToken.id, `${failurePrefix}%`)) + await expect( + resolveCoordinatedClientToken(input('refused-refresh', exchange)) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(exchange).toHaveBeenCalledTimes(2) + }) - await resolveCoordinatedClientToken(input('sweeper', provider('sweeper').exchange)) + it('never serves a refused token again once the refusal memo ends and an outage follows', async () => { + const exchange = issuingOnce('refused-then-outage', 4 * 60_000, rejecting()) + await resolveCoordinatedClientToken(input('refused-then-outage', exchange)) + await expect( + resolveCoordinatedClientToken(input('refused-then-outage', exchange)) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + await expireFailureRowsOf('refused-then-outage') + exchange.mockImplementation(failing(503)) - const rows = await db - .select({ id: clientCredentialToken.id }) - .from(clientCredentialToken) - .where(like(clientCredentialToken.id, `${failurePrefix}%`)) - expect(rows).toEqual([]) + await expect( + resolveCoordinatedClientToken(input('refused-then-outage', exchange)) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) + + it('keeps the token another secret minted when a different secret is refused', async () => { + const exchange = issuingOnce('refused-other-secret', 4 * 60_000, failing(503)) + await resolveCoordinatedClientToken(input('refused-other-secret', exchange)) + await expect( + resolveCoordinatedClientToken(input('refused-other-secret', rejecting(), 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + + await expect( + resolveCoordinatedClientToken(input('refused-other-secret', exchange)) + ).resolves.toMatchObject({ accessToken: 'refused-other-secret:token-1' }) + }) + + it('keeps refusing for every worker a refusal whose code the shared row cannot store', async () => { + const exchange = issuingOnce('consent-refresh', 4 * 60_000, async () => { + throw new TokenServiceAccountValidationError('consent_required', 400, { step: 'mint' }) + }) + await resolveCoordinatedClientToken(input('consent-refresh', exchange)) + + for (let call = 0; call < 2; call++) { + await expect( + resolveCoordinatedClientToken(input('consent-refresh', exchange)) + ).rejects.toBeInstanceOf(TokenServiceAccountValidationError) + } + expect(exchange).toHaveBeenCalledTimes(2) + const payload: unknown = JSON.parse((await failureRowOf('consent-refresh')).decrypted) + expect(previousBuildDecodesFailure.coordinated(payload)).toBe(true) + }) + + it('never serves a still-valid token to a different secret whose refresh fails', async () => { + const exchange = issuingOnce('rotated-refresh', 4 * 60_000, failing(429)) + await resolveCoordinatedClientToken(input('rotated-refresh', exchange)) + + await expect( + resolveCoordinatedClientToken(input('rotated-refresh', exchange, 'secret-2')) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) + + it('retries a quick outage once under the application lock, for every waiting resolution', async () => { + const { exchange: issue } = provider('retried') + const exchange = vi + .fn() + .mockRejectedValueOnce( + new TokenServiceAccountValidationError('provider_unavailable', 503, { step: 'mint' }) + ) + .mockImplementation(issue) + + const results = await Promise.all( + Array.from({ length: 5 }, () => resolveCoordinatedClientToken(input('retried', exchange))) + ) + + expect(new Set(results.map((result) => result.accessToken))).toEqual( + new Set(['retried:token-1']) + ) + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['as long as the provider asks', 'throttled-90s', 90_000, 90_000], + ['for at most five minutes', 'throttled-1h', 3_600_000, 300_000], + ])( + "shares a throttled exchange's Retry-After with every worker %s", + async (_case, application, retryAfterMs, replayMs) => { + const exchange = failing(429, retryAfterMs) + const before = Date.now() + + await expect( + resolveCoordinatedClientToken(input(application, exchange)) + ).rejects.toMatchObject({ code: 'provider_unavailable', status: 429 }) + await expect( + resolveCoordinatedClientToken(input(application, exchange)) + ).rejects.toMatchObject({ status: 429, replayForMs: expect.any(Number) }) + + expect(exchange).toHaveBeenCalledOnce() + const stored = await failureRowOf(application) + expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(replayMs) + expect(stored.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(replayMs) + } + ) + + it('replays a throttled exchange that names no Retry-After for at least a minute', async () => { + const exchange = failing(429) + const before = Date.now() + + await expect( + resolveCoordinatedClientToken(input('throttled-bare', exchange)) + ).rejects.toMatchObject({ code: 'provider_unavailable', status: 429 }) + + expect(exchange).toHaveBeenCalledOnce() + expect( + (await failureRowOf('throttled-bare')).expiresAt.getTime() - before + ).toBeGreaterThanOrEqual(60_000) + }) + + it('backs off a throttled answer after an outage from a minute, not by the outage streak', async () => { + const exchange = vi.fn() + for (const status of [502, 502]) { + exchange.mockImplementation(failing(status)) + await expect( + resolveCoordinatedClientToken(input('outage-then-throttled', exchange)) + ).rejects.toThrow() + await expireFailureRowsOf('outage-then-throttled') + } + exchange.mockImplementation(failing(429)) + const before = Date.now() + + await expect( + resolveCoordinatedClientToken(input('outage-then-throttled', exchange)) + ).rejects.toMatchObject({ status: 429 }) + + const replayMs = (await failureRowOf('outage-then-throttled')).expiresAt.getTime() - before + expect(replayMs).toBeGreaterThanOrEqual(60_000) + expect(replayMs).toBeLessThanOrEqual(72_000 + 2_500) + }) + + it('backs off throttling that continues for every worker longer than its first minute', async () => { + const exchange = failing(429) + await expect( + resolveCoordinatedClientToken(input('throttled-twice', exchange)) + ).rejects.toThrow() + await expireFailureRowsOf('throttled-twice') + const before = Date.now() + + await expect( + resolveCoordinatedClientToken(input('throttled-twice', exchange)) + ).rejects.toThrow() + + expect( + (await failureRowOf('throttled-twice')).expiresAt.getTime() - before + ).toBeGreaterThanOrEqual(96_000) + }) + + it.each([ + [ + 'the still-valid token while its refresh is remembered failing', + 'unlocked-fallback', + 'outage', + ], + ['a remembered refusal', 'unlocked-refusal', 'refusal'], + ] as const)( + 'serves %s without waiting for the application lock', + async (_case, application, remembered) => { + const appKey = applicationKey(NAMESPACE, [application]) + const fingerprint = sha256Json([application, 'secret-1']) + const token = await encryptSecret( + JSON.stringify({ accessToken: `${application}:token-1`, secretFingerprint: fingerprint }) + ) + const failure = await encryptSecret( + JSON.stringify( + remembered === 'outage' + ? { code: 'provider_unavailable', status: 503, consecutiveFailures: 1 } + : { code: 'invalid_credentials', status: 401 } + ) + ) + await db.insert(clientCredentialToken).values([ + { + id: appKey, + encryptedValue: token.encrypted, + accessTokenDigest: sha256Json(['coordinated_client_token', `${application}:token-1`]), + expiresAt: new Date(Date.now() + 4 * 60_000), + }, + { + id: `${appKey}:${sha256Json(fingerprint)}`, + encryptedValue: failure.encrypted, + expiresAt: new Date(Date.now() + 60_000), + }, + ]) + const { exchange } = provider(application) + const lockHeld = createDeferred() + const release = createDeferred() + const holder = db.transaction(async (tx) => { + await acquireAdvisoryXactLock(tx, `${NAMESPACE}_client_token`, appKey) + lockHeld.resolve() + await release.promise + }) + + try { + await lockHeld.promise + const outcome = await Promise.race([ + resolveCoordinatedClientToken(input(application, exchange)).then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ), + sleep(3_000).then(() => 'waited on the lock' as const), + ]) + + expect(outcome).toEqual( + remembered === 'outage' + ? { value: expect.objectContaining({ accessToken: `${application}:token-1` }) } + : { error: expect.objectContaining({ code: 'invalid_credentials' }) } + ) + expect(exchange).not.toHaveBeenCalled() + } finally { + release.resolve() + await holder + } + } + ) + + it('backs off an outage from a few seconds, longer while it continues, never past 30 seconds', async () => { + const exchange = failing(502) + + const replays: number[] = [] + for (let attempt = 0; attempt < 5; attempt++) { + const before = Date.now() + await expect(resolveCoordinatedClientToken(input('outage', exchange))).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + replays.push((await failureRowOf('outage')).expiresAt.getTime() - before) + await expireFailureRowsOf('outage') + } + + expect(replays[0]).toBeGreaterThanOrEqual(5_000) + expect(replays[0]).toBeLessThanOrEqual(6_000 + 2_500) + expect(replays[1]).toBeGreaterThanOrEqual(8_000) + expect(Math.max(...replays)).toBeLessThanOrEqual(30_000 + 2_500) + }) + + it('starts the backoff over after the provider recovers', async () => { + const { exchange: issue } = provider('recovered') + const exchange = vi + .fn() + .mockImplementation(failing(502)) + for (let attempt = 0; attempt < 3; attempt++) { + await expect(resolveCoordinatedClientToken(input('recovered', exchange))).rejects.toThrow() + await expireFailureRowsOf('recovered') + } + exchange.mockImplementationOnce(issue) + const { accessToken } = await resolveCoordinatedClientToken(input('recovered', exchange)) + await invalidateCoordinatedClientToken(accessToken) + exchange.mockImplementation(failing(502)) + + const before = Date.now() + await expect(resolveCoordinatedClientToken(input('recovered', exchange))).rejects.toThrow() + + expect((await failureRowOf('recovered')).expiresAt.getTime() - before).toBeLessThanOrEqual( + 6_000 + 2_500 + ) }) }) describe('default token rows shared with the previous build during a rolling deploy', () => { + /** Seeds a still-valid token and the previous build's failure memo of `status` for one secret. */ + async function seedPreviousBuildFailure(application: string, status: number) { + const appKey = applicationKey(NAMESPACE, [application]) + const fingerprint = sha256Json([application, 'secret-1']) + const token = await encryptSecret( + JSON.stringify({ accessToken: 'legacy-token', secretFingerprint: fingerprint }) + ) + const failure = await encryptSecret(JSON.stringify({ code: 'provider_unavailable', status })) + await db.insert(clientCredentialToken).values([ + { + id: appKey, + encryptedValue: token.encrypted, + accessTokenDigest: sha256Json(['coordinated_client_token', 'legacy-token']), + expiresAt: new Date(Date.now() + 4 * 60_000), + }, + { + id: `${appKey}:${sha256Json(fingerprint)}`, + encryptedValue: failure.encrypted, + expiresAt: new Date(Date.now() + 20_000), + }, + ]) + } + + it('serves a still-valid token when the previous build remembered an outage', async () => { + const application = 'legacy-outage' + await seedPreviousBuildFailure(application, 503) + const { exchange } = provider(application) + + await expect( + resolveCoordinatedClientToken(input(application, exchange)) + ).resolves.toMatchObject({ accessToken: 'legacy-token' }) + expect(exchange).not.toHaveBeenCalled() + }) + + it('replays a refusal the previous build stored as provider_unavailable without serving the token', async () => { + const application = 'legacy-unmapped-refusal' + await seedPreviousBuildFailure(application, 400) + const { exchange } = provider(application) + + await expect(resolveCoordinatedClientToken(input(application, exchange))).rejects.toMatchObject( + { code: 'provider_unavailable', status: 400 } + ) + expect(exchange).not.toHaveBeenCalled() + }) + + it('stores every outage and throttled answer as a failure the previous build reads', async () => { + for (const [application, exchange] of [ + ['legacy-readable-outage', failing(502)], + ['legacy-readable-throttle', failing(429, 60_000)], + ] as const) { + await expect(resolveCoordinatedClientToken(input(application, exchange))).rejects.toThrow() + + const stored = await failureRowOf(application) + const payload: unknown = JSON.parse(stored.decrypted) + expect(previousBuildDecodesFailure.coordinated(payload)).toBe(true) + expect(previousBuildDecodesFailure.vantaBeforeCoordination(payload)).toBe(true) + expect(stored.accessTokenDigest).toBeNull() + } + }) + it('stores tokens and refusals under the ids, digest, and payloads the previous build used', async () => { const application = ['legacy.myshopify.com', 'legacy-client'] const appKey = applicationKey(NAMESPACE, application) diff --git a/apps/sim/lib/credentials/coordinated-client-token.ts b/apps/sim/lib/credentials/coordinated-client-token.ts index 0fea2e04b00..aa6dd77b900 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.ts @@ -1,15 +1,28 @@ import { createHash } from 'node:crypto' import { db } from '@sim/db' import { clientCredentialToken } from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' import { and, eq, inArray, lt, sql } from 'drizzle-orm' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' -import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { + assessTokenRequestFailure, + isPermanentTokenFailureCode, + requestTokenWithRetry, + TOKEN_EXPIRY_MARGIN_MS, + TOKEN_FAILURE_STREAK_RETENTION_MS, + type TokenRequestFailure, +} from '@/lib/credentials/token-request-resilience' +import { + providerFailureCode, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' -/** A stored token is only handed out while at least this much of its lifetime remains. */ +const logger = createLogger('CoordinatedClientToken') + +/** A stored token is refreshed once less than this much of its lifetime remains. */ const MIN_REMAINING_MS = 5 * 60 * 1000 -const FAILURE_TTL_MS = 30_000 const DEFAULT_ACCESS_TOKEN_DIGEST_TAG = 'coordinated_client_token' /** The provider token a coordinated exchange produced. */ @@ -33,8 +46,19 @@ export interface CoordinatedClientTokenInput { /** Performs the provider exchange. Throws `TokenServiceAccountValidationError` on refusal. */ exchange: (signal?: AbortSignal) => Promise signal?: AbortSignal - /** Shortest remaining lifetime a stored token is still served with. Defaults to five minutes. */ + /** + * Remaining lifetime below which a stored token is refreshed. Defaults to five minutes. If the + * refresh fails with an outage or throttling, the token is still served until 30 seconds (or + * this value, when smaller) before it expires. + */ minRemainingMs?: number + /** + * The provider is asked for a token at most once per this interval while its exchanges fail: + * a quick outage is not retried inside the exchange, and every failure is replayed at least this + * long. For providers that rate-limit token minting itself (Vanta: 5 tokens a minute), where + * every exchange the provider processes also revokes the application's previous token. + */ + minExchangeIntervalMs?: number /** * A request setting beyond the secret that a remembered exchange failure is specific to, so a * refusal for one setting does not block another. @@ -61,15 +85,27 @@ export interface CoordinatedClientToken { expiresInSeconds: number } +type StoredRow = typeof clientCredentialToken.$inferSelect + interface StoredApplicationToken { accessToken: string secretFingerprint: string record: Readonly> } +/** + * A remembered exchange failure, replayed until its row's `expiresAt`. Builds before failure + * kinds stored `{ code, status }` only and ignore other keys, so `consecutiveFailures` is + * written only for outages and throttling, to grow their backoff while they continue, `throttled` + * only for throttling, so its streak is kept apart from an outage's, and `refusal` only for a + * refusal whose code those builds read as `provider_unavailable`. + */ interface StoredFailure { code: 'invalid_credentials' | 'grant_not_permitted' | 'site_not_found' | 'provider_unavailable' status: number + consecutiveFailures?: number + throttled?: true + refusal?: true } const STORED_FAILURE_CODES: ReadonlySet = new Set([ @@ -89,16 +125,18 @@ function accessTokenDigestOf(accessToken: string, tag = DEFAULT_ACCESS_TOKEN_DIG } /** - * Deletes a bounded batch of expired failure memos (rows without a token digest). Run it outside - * the transaction that takes an application lock, so its row locks are released before any - * provider call. + * Deletes a bounded batch of failure memos (rows without a token digest) that expired more than + * {@link TOKEN_FAILURE_STREAK_RETENTION_MS} ago; a more recently expired one is kept so the next + * failure continues its backoff. Run it outside the transaction that takes an application lock, + * so its row locks are released before any provider call. */ async function deleteExpiredClientTokenFailures(): Promise { + const retentionSeconds = TOKEN_FAILURE_STREAK_RETENTION_MS / 1000 await db.execute(sql` DELETE FROM ${clientCredentialToken} WHERE ${clientCredentialToken.id} IN ( SELECT ${clientCredentialToken.id} FROM ${clientCredentialToken} WHERE ${clientCredentialToken.accessTokenDigest} IS NULL - AND ${clientCredentialToken.expiresAt} < now() + AND ${clientCredentialToken.expiresAt} < now() - make_interval(secs => ${retentionSeconds}) ORDER BY ${clientCredentialToken.expiresAt} LIMIT 100 FOR UPDATE SKIP LOCKED )`) } @@ -168,7 +206,65 @@ function decodeFailure(value: string, step: string): StoredFailure { typeof parsed.status !== 'number' ) throw unavailable(step, 'stored token failure is malformed') - return { code: parsed.code as StoredFailure['code'], status: parsed.status } + return { + code: parsed.code as StoredFailure['code'], + status: parsed.status, + ...(Number.isSafeInteger(parsed.consecutiveFailures) && Number(parsed.consecutiveFailures) > 0 + ? { consecutiveFailures: Number(parsed.consecutiveFailures) } + : {}), + ...(parsed.throttled === true ? { throttled: true } : {}), + ...(parsed.refusal === true ? { refusal: true } : {}), + } +} + +/** + * Whether a remembered failure refused the credentials, so no stored token is served for it. A + * build before failure kinds stored a refusal whose code it could not store as + * `provider_unavailable` with the provider's status and no streak count, so such a row whose + * status names a refusal (a non-transient 4xx) is one too. + */ +function isRememberedRefusal(failure: StoredFailure): boolean { + if (failure.refusal === true || isPermanentTokenFailureCode(failure.code)) return true + return ( + failure.consecutiveFailures === undefined && + isPermanentTokenFailureCode(providerFailureCode(failure.status)) + ) +} + +/** The error a remembered failure is replayed as, with how long it is still replayed. */ +function replayedFailure( + failure: StoredFailure, + step: string, + replayMs: number +): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError( + failure.code, + failure.status, + { step }, + { replayForMs: Math.max(replayMs, 0) } + ) +} + +/** + * The outage or throttling streak before this exchange, read from the application's last failure + * memo for this secret once its replay has ended. A refusal, or anything unreadable, starts a new + * streak. + */ +async function failureStreakBefore( + row: StoredRow | undefined, + step: string +): Promise | undefined> { + if (!row || Date.now() - row.expiresAt.getTime() > TOKEN_FAILURE_STREAK_RETENTION_MS) return + try { + const failure = decodeFailure((await decryptSecret(row.encryptedValue)).decrypted, step) + if (isRememberedRefusal(failure)) return + return { + kind: failure.throttled ? 'throttled' : 'transient', + consecutiveFailures: failure.consecutiveFailures ?? 1, + } + } catch { + return + } } function remainingSeconds(expiresAt: Date): number { @@ -186,11 +282,19 @@ function isServable(expiresAt: Date, minRemainingMs: number): boolean { * to reuse one token rather than accumulate them. Minting happens only under a * transaction-scoped advisory lock keyed by the application, so two workers never race two * tokens into existence; a token minted with a different secret is never served, and a token the - * provider rejects is expired through {@link invalidateCoordinatedClientToken}. An exchange that - * fails validation, whether refused for the credentials or answered with an outage, is remembered - * for 30 seconds, the same backoff the per-instance mint cache applies, so a hot workflow neither - * hammers the token endpoint with a revoked secret nor floods a struggling one. Tokens are - * encrypted at rest. + * provider rejects is expired through {@link invalidateCoordinatedClientToken}. + * + * A quick outage is retried once inside the exchange (unless `minExchangeIntervalMs` is set). An + * exchange that still fails validation is remembered for every worker, with the policy the + * per-instance mint cache applies (see `assessTokenRequestFailure`): a refusal of the credentials + * for 30 seconds, a throttled answer as long as its `Retry-After` asks (at most five minutes) or + * for a minute growing to five without one, and an outage for 5 seconds growing to 30 while it + * continues, so a hot workflow neither hammers the token endpoint with a revoked secret nor floods + * a struggling one. While an outage or throttling is remembered, the stored token for the same + * secret is still served until shortly before it actually expires; a refusal is reported at once + * and expires that secret's stored token, so no later outage serves it again. + * A remembered failure, and that fallback, are served from committed rows without taking the + * application lock. Tokens are encrypted at rest. */ export async function resolveCoordinatedClientToken( input: CoordinatedClientTokenInput @@ -206,107 +310,191 @@ export async function resolveCoordinatedClientToken( : [secretFingerprint, input.failureKeyQualifier] )}` - const serveIfFresh = async ( - row: typeof clientCredentialToken.$inferSelect | undefined, + const fallbackRemainingMs = Math.min(TOKEN_EXPIRY_MARGIN_MS, minRemainingMs) + const decodedRows = new WeakMap>() + + /** + * The token `row` holds for this secret while more than `remainingMs` of it is left. The record + * is decoded and its pinned state verified (throwing on a malformed record or a mismatch) once + * per row, whenever it could be served, and for an expired record too when `verifyExpired` is + * set and settings are pinned. + */ + const serveStored = async ( + row: StoredRow | undefined, + remainingMs: number, verifyExpired = false - ) => { + ): Promise => { if (!row) return undefined - const servable = isServable(row.expiresAt, minRemainingMs) + const servable = isServable(row.expiresAt, remainingMs) if (!servable && !(verifyExpired && pinnedState)) return undefined - const stored = decodeApplicationToken((await decryptSecret(row.encryptedValue)).decrypted, step) - pinnedState?.verify(stored.record) + let decoded = decodedRows.get(row) + if (!decoded) { + decoded = decryptSecret(row.encryptedValue).then(({ decrypted }) => { + const stored = decodeApplicationToken(decrypted, step) + pinnedState?.verify(stored.record) + return stored + }) + decodedRows.set(row, decoded) + } + const stored = await decoded if (!servable || stored.secretFingerprint !== secretFingerprint) return undefined return { accessToken: stored.accessToken, expiresInSeconds: remainingSeconds(row.expiresAt) } } + /** + * The stored token for this secret while it has not yet run out, served while its refresh fails + * with an outage or throttling. A record that does not decode or verify is never a fallback. + */ + const fallbackFrom = (row: StoredRow | undefined) => + serveStored(row, fallbackRemainingMs).catch(() => undefined) + + /** + * Whether `row` holds a token minted with this secret, expired or not, so a refusal of the + * secret ends that token too and a token another secret minted stays in service. + */ + const holdsTokenForSecret = async (row: StoredRow | undefined) => + (await serveStored(row, Number.NEGATIVE_INFINITY).catch(() => undefined)) !== undefined + + /** + * Replays the application's unexpired failure memo for this secret: the still-valid token for a + * remembered outage or throttling, the failure otherwise. `undefined` when nothing is remembered. + * Reading committed rows needs no lock, so this also runs before the application lock is taken. + */ + const replayRemembered = async ( + applicationRow: StoredRow | undefined, + failureRow: StoredRow | undefined + ): Promise< + CoordinatedClientToken | { failure: TokenServiceAccountValidationError } | undefined + > => { + if (!failureRow || failureRow.expiresAt.getTime() <= Date.now()) return undefined + const failure = decodeFailure((await decryptSecret(failureRow.encryptedValue)).decrypted, step) + if (!isRememberedRefusal(failure)) { + const fallback = await fallbackFrom(applicationRow) + if (fallback) return fallback + } + return { failure: replayedFailure(failure, step, failureRow.expiresAt.getTime() - Date.now()) } + } + + const settle = ( + outcome: CoordinatedClientToken | { failure: TokenServiceAccountValidationError } + ): CoordinatedClientToken => { + signal?.throwIfAborted() + if ('failure' in outcome) throw outcome.failure + return outcome + } + try { - const [current] = await db + const current = await db .select() .from(clientCredentialToken) - .where(eq(clientCredentialToken.id, applicationKey)) - .limit(1) - const fresh = await serveIfFresh(current) + .where(inArray(clientCredentialToken.id, [applicationKey, failureKey])) + const currentApplication = current.find((entry) => entry.id === applicationKey) + const fresh = await serveStored(currentApplication, minRemainingMs, true) if (fresh) return fresh + const remembered = await replayRemembered( + currentApplication, + current.find((entry) => entry.id === failureKey) + ) + if (remembered) return settle(remembered) await deleteExpiredClientTokenFailures() - const outcome = await db.transaction( - async (tx) => { - await tx.execute(sql`SET LOCAL lock_timeout = '15s'`) - await acquireAdvisoryXactLock(tx, `${namespace}_client_token`, applicationKey) - signal?.throwIfAborted() - const entries = await tx - .select() - .from(clientCredentialToken) - .where(inArray(clientCredentialToken.id, [applicationKey, failureKey])) - const minted = await serveIfFresh( - entries.find((entry) => entry.id === applicationKey), - true + const outcome = await db.transaction< + CoordinatedClientToken | { failure: TokenServiceAccountValidationError } + >(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '15s'`) + await acquireAdvisoryXactLock(tx, `${namespace}_client_token`, applicationKey) + signal?.throwIfAborted() + const entries = await tx + .select() + .from(clientCredentialToken) + .where(inArray(clientCredentialToken.id, [applicationKey, failureKey])) + const applicationRow = entries.find((entry) => entry.id === applicationKey) + const minted = await serveStored(applicationRow, minRemainingMs, true) + if (minted) return minted + const failureRow = entries.find((entry) => entry.id === failureKey) + const rememberedUnderLock = await replayRemembered(applicationRow, failureRow) + if (rememberedUnderLock) return rememberedUnderLock + signal?.throwIfAborted() + try { + const exchanged = + input.minExchangeIntervalMs === undefined + ? await requestTokenWithRetry(() => input.exchange(signal), signal) + : await input.exchange(signal) + const { encrypted } = await encryptSecret( + JSON.stringify({ + accessToken: exchanged.accessToken, + secretFingerprint, + ...pinnedState?.values, + }) ) - if (minted) return minted - const failure = entries.find( - (entry) => entry.id === failureKey && entry.expiresAt.getTime() > Date.now() + const values = { + encryptedValue: encrypted, + accessTokenDigest: accessTokenDigestOf(exchanged.accessToken, input.accessTokenDigestTag), + expiresAt: exchanged.expiresAt, + updatedAt: new Date(), + } + await tx + .insert(clientCredentialToken) + .values({ id: applicationKey, ...values }) + .onConflictDoUpdate({ target: clientCredentialToken.id, set: values }) + await tx.delete(clientCredentialToken).where(eq(clientCredentialToken.id, failureKey)) + return { + accessToken: exchanged.accessToken, + expiresInSeconds: remainingSeconds(exchanged.expiresAt), + } + } catch (error) { + if (!(error instanceof TokenServiceAccountValidationError)) throw error + const assessed = assessTokenRequestFailure( + error, + await failureStreakBefore(failureRow, step) ) - if (failure) { - return { - failure: decodeFailure((await decryptSecret(failure.encryptedValue)).decrypted, step), - } + const replayMs = Math.max(assessed.replayMs, input.minExchangeIntervalMs ?? 0) + const code = STORED_FAILURE_CODES.has(error.code) + ? (error.code as StoredFailure['code']) + : 'provider_unavailable' + const failure: StoredFailure = { + code, + status: error.status, + ...(assessed.kind !== 'permanent' + ? { + consecutiveFailures: assessed.consecutiveFailures, + ...(assessed.kind === 'throttled' ? { throttled: true } : {}), + } + : isPermanentTokenFailureCode(code) + ? {} + : { refusal: true }), } - signal?.throwIfAborted() - try { - const exchanged = await input.exchange(signal) - const { encrypted } = await encryptSecret( - JSON.stringify({ - accessToken: exchanged.accessToken, - secretFingerprint, - ...pinnedState?.values, - }) - ) - const values = { - encryptedValue: encrypted, - accessTokenDigest: accessTokenDigestOf( - exchanged.accessToken, - input.accessTokenDigestTag - ), - expiresAt: exchanged.expiresAt, - updatedAt: new Date(), - } - await tx - .insert(clientCredentialToken) - .values({ id: applicationKey, ...values }) - .onConflictDoUpdate({ target: clientCredentialToken.id, set: values }) - await tx.delete(clientCredentialToken).where(eq(clientCredentialToken.id, failureKey)) - return { - accessToken: exchanged.accessToken, - expiresInSeconds: remainingSeconds(exchanged.expiresAt), + const { encrypted } = await encryptSecret(JSON.stringify(failure)) + const expiresAt = new Date(Date.now() + replayMs) + await tx + .insert(clientCredentialToken) + .values({ id: failureKey, encryptedValue: encrypted, expiresAt }) + .onConflictDoUpdate({ + target: clientCredentialToken.id, + set: { encryptedValue: encrypted, expiresAt, updatedAt: new Date() }, + }) + if (assessed.kind === 'permanent') { + if (await holdsTokenForSecret(applicationRow)) { + await tx + .update(clientCredentialToken) + .set({ expiresAt: new Date(0), updatedAt: new Date() }) + .where(eq(clientCredentialToken.id, applicationKey)) } - } catch (error) { - if (!(error instanceof TokenServiceAccountValidationError)) throw error - const failure: StoredFailure = { - code: STORED_FAILURE_CODES.has(error.code) - ? (error.code as StoredFailure['code']) - : 'provider_unavailable', - status: error.status, - } - const { encrypted } = await encryptSecret(JSON.stringify(failure)) - const expiresAt = new Date(Date.now() + FAILURE_TTL_MS) - await tx - .insert(clientCredentialToken) - .values({ id: failureKey, encryptedValue: encrypted, expiresAt }) - .onConflictDoUpdate({ - target: clientCredentialToken.id, - set: { encryptedValue: encrypted, expiresAt, updatedAt: new Date() }, - }) - return { failure } + return { failure: replayedFailure(failure, step, replayMs) } + } + const fallback = await fallbackFrom(applicationRow) + if (fallback) { + logger.warn('Serving a still-valid client-credentials token after its refresh failed', { + namespace, + failure: assessed.kind, + retryInMs: replayMs, + }) + return fallback } + return { failure: replayedFailure(failure, step, replayMs) } } - ) - signal?.throwIfAborted() - if ('failure' in outcome) { - throw new TokenServiceAccountValidationError(outcome.failure.code, outcome.failure.status, { - step, - }) - } - return outcome + }) + return settle(outcome) } catch (error) { signal?.throwIfAborted() if (error instanceof TokenServiceAccountValidationError) throw error diff --git a/apps/sim/lib/credentials/database-connections/descriptors.ts b/apps/sim/lib/credentials/database-connections/descriptors.ts new file mode 100644 index 00000000000..f5ec9eec7bb --- /dev/null +++ b/apps/sim/lib/credentials/database-connections/descriptors.ts @@ -0,0 +1,179 @@ +/** + * Client-safe descriptors for saved database connections. + * + * A saved database connection is a `service_account`-type credential holding + * the host, port, database, login, password, and transport-security settings + * a SQL block otherwise takes inline. It is verified once with a cheap + * `SELECT 1` through the block's own guarded connection path, stored + * encrypted, and read back inside the tool operation on every run; nothing + * about it reaches the tool params, the model, or the browser after saving. + * Server-side verification and resolution live in + * `@/lib/credentials/database-connections/server`. + */ +import type { MssqlToggle } from '@/lib/internal/mssql/schema' +import type { MysqlSslMode } from '@/lib/internal/mysql/schema' +import type { PostgresqlSslMode } from '@/lib/internal/postgresql/schema' +import { + MSSQL_SERVICE_ACCOUNT_PROVIDER_ID, + MYSQL_SERVICE_ACCOUNT_PROVIDER_ID, + POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/oauth/types' + +/** The `service_account` provider id of a saved PostgreSQL, MySQL, or SQL Server connection. */ +export type DatabaseConnectionProviderId = + | typeof POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID + | typeof MYSQL_SERVICE_ACCOUNT_PROVIDER_ID + | typeof MSSQL_SERVICE_ACCOUNT_PROVIDER_ID + +/** A transport-security setting the connection stores beside its login. */ +interface DatabaseTlsField { + /** The tool param, and the stored connection field, it fills. */ + id: 'ssl' | 'encrypt' | 'trustServerCertificate' + label: string + options: readonly { value: string; label: string }[] + defaultValue: string + hint: string +} + +/** + * Everything the connect modal, the provider catalog, and the blocks need to + * know about one saved database connection type, without server-only code. + */ +export interface DatabaseConnectionDescriptor { + providerId: DatabaseConnectionProviderId + /** The OAuth service id its blocks' picker and tools name. */ + service: 'postgresql' | 'mysql' | 'mssql' + /** Product name used in modal copy and errors. */ + label: string + defaultPort: number + usernamePlaceholder: string + tls: readonly DatabaseTlsField[] + docsUrl: string +} + +/** + * Picker options in display order. Keyed by the operation schema's own value + * type, so a value the operations add or drop fails to compile here instead of + * drifting between the picker and what a save accepts. + */ +function optionsFrom(labels: Record) { + return Object.entries(labels).map(([value, label]) => ({ value, label })) +} + +export const DATABASE_CONNECTION_DESCRIPTORS = { + [POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, + service: 'postgresql', + label: 'PostgreSQL', + defaultPort: 5432, + usernamePlaceholder: 'postgres', + tls: [ + { + id: 'ssl', + label: 'SSL mode', + options: optionsFrom({ + preferred: 'Preferred', + required: 'Required', + disabled: 'Disabled', + }), + defaultValue: 'preferred' satisfies PostgresqlSslMode, + hint: 'Required encrypts the connection without verifying the server certificate. Preferred falls back to an unencrypted connection when the server offers no TLS, which an attacker on the network can force; choose Required when the server supports TLS.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/postgresql', + }, + [MYSQL_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: MYSQL_SERVICE_ACCOUNT_PROVIDER_ID, + service: 'mysql', + label: 'MySQL', + defaultPort: 3306, + usernamePlaceholder: 'root', + tls: [ + { + id: 'ssl', + label: 'SSL mode', + options: optionsFrom({ + preferred: 'Preferred', + required: 'Required', + disabled: 'Disabled', + }), + defaultValue: 'preferred' satisfies MysqlSslMode, + hint: "Required encrypts the connection and verifies the certificate chain against the runtime's trust store, but not the host name (like MySQL's VERIFY_CA). Preferred encrypts without verifying the certificate. Both refuse a server that offers no TLS rather than connect unencrypted.", + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/mysql', + }, + [MSSQL_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: MSSQL_SERVICE_ACCOUNT_PROVIDER_ID, + service: 'mssql', + label: 'Microsoft SQL Server', + defaultPort: 1433, + usernamePlaceholder: 'sa', + tls: [ + { + id: 'encrypt', + label: 'Encrypt connection', + options: optionsFrom({ enabled: 'Enabled', disabled: 'Disabled' }), + defaultValue: 'enabled' satisfies MssqlToggle, + hint: 'Requests TLS. A server that does not support encryption gets an unencrypted session rather than an error.', + }, + { + id: 'trustServerCertificate', + label: 'Trust server certificate', + options: optionsFrom({ disabled: 'Disabled', enabled: 'Enabled' }), + defaultValue: 'disabled' satisfies MssqlToggle, + hint: 'Enable only for a server with a self-signed certificate; it skips certificate validation.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/mssql', + }, +} as const satisfies Record + +const DATABASE_LOGIN_FIELD_IDS = ['host', 'port', 'database', 'username', 'password'] as const + +/** A field a saved connection of provider `P` stores: a login field or one of its TLS settings. */ +export type DatabaseConnectionFieldId

    = + | (typeof DATABASE_LOGIN_FIELD_IDS)[number] + | (typeof DATABASE_CONNECTION_DESCRIPTORS)[P]['tls'][number]['id'] + +/** + * Every field a saved connection of this provider stores: the login, then its + * transport-security settings. The stored schema keeps exactly these fields + * and the tools declare exactly these as credential-supplied, so what a blob + * persists, what a run replaces, and what the block hides share one list. + * Changing a provider's list changes its stored blob, so it needs a new + * secret type. + */ +export function databaseConnectionFieldIds

    ( + providerId: P +): DatabaseConnectionFieldId

    [] { + return [ + ...DATABASE_LOGIN_FIELD_IDS, + ...tlsFieldIds(DATABASE_CONNECTION_DESCRIPTORS[providerId].tls), + ] +} + +function tlsFieldIds( + fields: Fields +): Fields[number]['id'][] { + return fields.map((field) => field.id) +} + +/** Whether a provider id names a saved database connection (`postgresql-service-account`, `mysql-service-account`, or `mssql-service-account`). */ +export function isDatabaseConnectionProviderId( + value: string | null | undefined +): value is DatabaseConnectionProviderId { + return typeof value === 'string' && Object.hasOwn(DATABASE_CONNECTION_DESCRIPTORS, value) +} + +/** + * The saved-connection descriptor for a database provider id, or `undefined` + * when the id is missing or names a provider that is not a database connection. + */ +export function getDatabaseConnectionDescriptor( + providerId: string | null | undefined +): DatabaseConnectionDescriptor | undefined { + return isDatabaseConnectionProviderId(providerId) + ? DATABASE_CONNECTION_DESCRIPTORS[providerId] + : undefined +} diff --git a/apps/sim/lib/credentials/database-connections/server.test.ts b/apps/sim/lib/credentials/database-connections/server.test.ts new file mode 100644 index 00000000000..0e531d4000e --- /dev/null +++ b/apps/sim/lib/credentials/database-connections/server.test.ts @@ -0,0 +1,351 @@ +/** + * @vitest-environment node + */ +import { credential } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' +import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' + +const probes = vi.hoisted(() => ({ + postgresql: vi.fn(), + mysql: vi.fn(), + mssql: vi.fn(), +})) +vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/internal/postgresql/operations', () => ({ + executePostgresqlQuery: probes.postgresql, +})) +vi.mock('@/lib/internal/mysql/operations', () => ({ executeMysqlQuery: probes.mysql })) +vi.mock('@/lib/internal/mssql/operations', () => ({ executeMssqlQuery: probes.mssql })) + +import type { DatabaseConnectionProviderId } from '@/lib/credentials/database-connections/descriptors' +import { + verifyAndEncryptDatabaseConnection, + withSavedDatabaseConnection, +} from '@/lib/credentials/database-connections/server' +import { + MSSQL_CREDENTIAL_OAUTH, + MYSQL_CREDENTIAL_OAUTH, + POSTGRESQL_CREDENTIAL_OAUTH, +} from '@/tools/shared/saved-credentials' + +const connection = { + host: 'db.example.com', + port: 5432, + database: 'app', + username: 'app_user', + password: ' exact password ', + ssl: 'required', +} +const stored = { type: 'postgresql_connection_v1', connection } +const { ssl: _ssl, ...connectionWithoutTls } = connection +const row = { + workspaceId: 'workspace-1', + organizationId: null, + providerId: 'postgresql-service-account', + type: 'service_account', + revokedAt: null, + encryptedServiceAccountKey: 'ciphertext', +} + +const savedConnectionContext = { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + authorizedServiceAccountCredential: { credentialId: 'credential-1', service: 'postgresql' }, +} + +const inlineInput = { + host: 'attacker.example.com', + port: 1, + database: 'other', + username: 'other', + password: 'inline', + ssl: 'disabled', + query: 'SELECT 2', +} +const { ssl: _inlineSsl, ...inlineInputWithoutTls } = inlineInput + +function call(overrides: Partial = {}): InternalToolOperationCall { + return { + toolId: 'postgresql_query', + input: inlineInput, + headers: new Headers(), + context: { workflowId: 'workflow-1', workspaceId: 'workspace-1' }, + requestId: 'request-1', + ...overrides, + } +} + +const echo = vi.fn(async (request: InternalToolOperationCall) => Response.json(request.input)) + +describe('saved database connections', () => { + beforeEach(() => { + echo.mockClear() + resetDbChainMock() + for (const probe of Object.values(probes)) { + probe.mockReset() + probe.mockResolvedValue({ rows: [{ sim_connection_ok: 1 }], rowCount: 1 }) + } + encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(stored) }) + }) + + describe('saving', () => { + it.each([ + { + providerId: 'postgresql-service-account', + service: 'postgresql', + probe: probes.postgresql, + login: connectionWithoutTls, + defaults: { ssl: 'preferred' }, + probeOnly: {}, + }, + { + providerId: 'mysql-service-account', + service: 'mysql', + probe: probes.mysql, + login: connectionWithoutTls, + defaults: { ssl: 'preferred' }, + probeOnly: {}, + }, + { + providerId: 'mssql-service-account', + service: 'mssql', + probe: probes.mssql, + login: { ...connectionWithoutTls, port: 1433 }, + defaults: { encrypt: 'enabled', trustServerCertificate: 'disabled' }, + probeOnly: { connectionTimeout: 15_000 }, + }, + ] as const)( + 'proves the exact $service login and a later run reads it back with its TLS defaults', + async ({ providerId, service, probe, login, defaults, probeOnly }) => { + encryptionMockFns.mockEncryptSecret.mockImplementation(async (plain: string) => ({ + encrypted: plain, + iv: 'iv', + })) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted, + })) + + const saved = await verifyAndEncryptDatabaseConnection( + providerId satisfies DatabaseConnectionProviderId, + JSON.stringify(login) + ) + expect(probe.mock.calls[0]?.[0]).toEqual({ + ...login, + ...defaults, + ...probeOnly, + query: 'SELECT 1 AS sim_connection_ok', + }) + expect(saved).toMatchObject({ + username: 'app_user', + displayName: `app_user@db.example.com/app`, + }) + + queueTableRows(credential, [ + { ...row, providerId, encryptedServiceAccountKey: saved.encryptedServiceAccountKey }, + ]) + const response = await withSavedDatabaseConnection( + providerId, + echo + )( + call({ + context: { + ...savedConnectionContext, + authorizedServiceAccountCredential: { credentialId: 'credential-1', service }, + }, + }) + ) + + expect(await response.json()).toEqual({ ...inlineInput, ...login, ...defaults }) + } + ) + + it.each([ + ['malformed JSON', '{'], + ['an unknown connection field', JSON.stringify({ ...connection, sslrootcert: '...' })], + ['a port outside the TCP range', JSON.stringify({ ...connection, port: 70_000 })], + ['a missing password', JSON.stringify({ ...connection, password: '' })], + ['an SSL mode the tools do not support', JSON.stringify({ ...connection, ssl: 'allow' })], + ['a body over 64 KiB', `${JSON.stringify(connection)}${' '.repeat(70_000)}`], + ])('refuses %s without connecting or storing', async (_case, serialized) => { + await expect( + verifyAndEncryptDatabaseConnection('postgresql-service-account', serialized) + ).rejects.toThrow(/PostgreSQL connection/) + expect(probes.postgresql).not.toHaveBeenCalled() + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + + it('keeps a driver error, which can echo the login, out of the message and storage', async () => { + probes.postgresql.mockRejectedValue(new Error('auth failed for " exact password "')) + + const error = await verifyAndEncryptDatabaseConnection( + 'postgresql-service-account', + JSON.stringify(connection) + ).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe('Could not verify the PostgreSQL connection') + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + + it('refuses a server that does not answer SELECT 1 with 1', async () => { + probes.postgresql.mockResolvedValue({ rows: [{ sim_connection_ok: 0 }], rowCount: 1 }) + + await expect( + verifyAndEncryptDatabaseConnection('postgresql-service-account', JSON.stringify(connection)) + ).rejects.toThrow('Could not verify the PostgreSQL connection') + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + }) + + describe('running a tool', () => { + const handler = echo + + it('hands an inline-connection call to the handler untouched', async () => { + const request = call() + + await withSavedDatabaseConnection('postgresql-service-account', handler)(request) + + expect(handler.mock.calls[0]?.[0]).toBe(request) + }) + + it('ignores a credential the executor authorized for another service', async () => { + const request = call({ + context: { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + authorizedServiceAccountCredential: { credentialId: 'credential-1', service: 'mysql' }, + }, + }) + + await withSavedDatabaseConnection('postgresql-service-account', handler)(request) + + expect(handler.mock.calls[0]?.[0]).toBe(request) + }) + + it('replaces every connection field with the saved connection and keeps the rest', async () => { + queueTableRows(credential, [row]) + + const response = await withSavedDatabaseConnection( + 'postgresql-service-account', + handler + )( + call({ + context: savedConnectionContext, + }) + ) + + expect(await response.json()).toEqual({ ...connection, query: 'SELECT 2' }) + }) + + it.each([ + { + providerId: 'postgresql-service-account', + oauth: POSTGRESQL_CREDENTIAL_OAUTH, + saved: connection, + input: inlineInput, + }, + { + providerId: 'mysql-service-account', + oauth: MYSQL_CREDENTIAL_OAUTH, + saved: connection, + input: inlineInput, + }, + { + providerId: 'mssql-service-account', + oauth: MSSQL_CREDENTIAL_OAUTH, + saved: connectionWithoutTls, + input: { + ...inlineInputWithoutTls, + encrypt: 'disabled', + trustServerCertificate: 'enabled', + connectionTimeout: 30_000, + }, + }, + ] as const)( + 'a saved $oauth.provider connection replaces exactly the params its tools declare it supplies', + async ({ providerId, oauth, saved, input }) => { + encryptionMockFns.mockEncryptSecret.mockImplementation(async (plain: string) => ({ + encrypted: plain, + iv: 'iv', + })) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted, + })) + const { encryptedServiceAccountKey } = await verifyAndEncryptDatabaseConnection( + providerId, + JSON.stringify(saved) + ) + queueTableRows(credential, [{ ...row, providerId, encryptedServiceAccountKey }]) + + const response = await withSavedDatabaseConnection( + providerId, + echo + )( + call({ + input, + context: { + ...savedConnectionContext, + authorizedServiceAccountCredential: { + credentialId: 'credential-1', + service: oauth.provider, + }, + }, + }) + ) + + const ran: Record = await response.json() + const inline: Record = input + const replaced = Object.keys(ran).filter((key) => ran[key] !== inline[key]) + expect(replaced.sort()).toEqual([...oauth.credentialSuppliedParams].sort()) + } + ) + + it.each([ + ['a credential for another engine', { providerId: 'mysql-service-account' }, stored], + ['an organization credential', { organizationId: 'organization-1' }, stored], + ['another engine’s blob', {}, { ...stored, type: 'mysql_connection_v1' }], + [ + 'a blob with an unknown stored field', + {}, + { ...stored, connection: { ...connection, extra: 'x' } }, + ], + ])('refuses %s with 403 and never runs', async (_case, rowOverride, blob) => { + queueTableRows(credential, [{ ...row, ...rowOverride }]) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(blob) }) + + const response = await withSavedDatabaseConnection( + 'postgresql-service-account', + handler + )(call({ context: savedConnectionContext })) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'PostgreSQL connection is unavailable' }) + expect(handler).not.toHaveBeenCalled() + }) + + it('stops when the run is aborted while the connection loads', async () => { + const controller = new AbortController() + queueTableRows(credential, [row]) + encryptionMockFns.mockDecryptSecret.mockImplementation(async () => { + controller.abort(new Error('run cancelled')) + return { decrypted: JSON.stringify(stored) } + }) + + await expect( + withSavedDatabaseConnection( + 'postgresql-service-account', + handler + )( + call({ + signal: controller.signal, + context: savedConnectionContext, + }) + ) + ).rejects.toThrow('run cancelled') + expect(handler).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/credentials/database-connections/server.ts b/apps/sim/lib/credentials/database-connections/server.ts new file mode 100644 index 00000000000..74ed319d9ba --- /dev/null +++ b/apps/sim/lib/credentials/database-connections/server.ts @@ -0,0 +1,266 @@ +import { isRecordLike } from '@sim/utils/object' +import { z } from 'zod' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + DATABASE_CONNECTION_DESCRIPTORS, + type DatabaseConnectionDescriptor, + type DatabaseConnectionFieldId, + type DatabaseConnectionProviderId, + databaseConnectionFieldIds, +} from '@/lib/credentials/database-connections/descriptors' +import { + type CredentialOwnerScope, + readOwnerScopedServiceAccountSecret, +} from '@/lib/credentials/owner-scoped-secret.server' +import { + MSSQL_DEFAULT_CONNECTION_TIMEOUT_MS, + mssqlConnectionInputSchema, +} from '@/lib/internal/mssql/schema' +import { mysqlConnectionInputSchema } from '@/lib/internal/mysql/schema' +import { postgresqlConnectionInputSchema } from '@/lib/internal/postgresql/schema' +import type { + InternalToolOperationCall, + InternalToolOperationHandler, +} from '@/lib/internal/tool-operations/types' +import { + MSSQL_SERVICE_ACCOUNT_PROVIDER_ID, + MYSQL_SERVICE_ACCOUNT_PROVIDER_ID, + POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/oauth/types' + +const MAX_SERIALIZED_CONNECTION_BYTES = 64 * 1024 +const VERIFY_TIMEOUT_MS = 30_000 +const VERIFY_QUERY = 'SELECT 1 AS sim_connection_ok' + +/** + * Tighter bounds a stored login keeps on top of the operation's own connection + * schema; the transport-security fields and their defaults come from the + * operation schema unchanged, so a save never accepts a value a run would refuse. + */ +const storedLoginBounds = { + host: z.string().trim().min(1).max(253), + port: z.coerce.number().int().min(1).max(65535), + database: z.string().trim().min(1).max(255), + username: z.string().trim().min(1).max(255), + password: z.string().min(1).max(4096), +} + +/** + * Picks exactly the provider's stored connection fields out of its operation + * schema. A field the operation adds later (as SQL Server's per-block + * connection timeout already is) stays out of the stored blob and is never + * pinned by a saved connection. + */ +function storedFieldMask

    (providerId: P) { + const mask: Partial, true>> = {} + for (const id of databaseConnectionFieldIds(providerId)) mask[id] = true + return mask as Record, true> +} + +const postgresqlStoredConnectionSchema = postgresqlConnectionInputSchema + .pick(storedFieldMask(POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID)) + .extend(storedLoginBounds) + .strict() +const mysqlStoredConnectionSchema = mysqlConnectionInputSchema + .pick(storedFieldMask(MYSQL_SERVICE_ACCOUNT_PROVIDER_ID)) + .extend(storedLoginBounds) + .strict() +const mssqlStoredConnectionSchema = mssqlConnectionInputSchema + .pick(storedFieldMask(MSSQL_SERVICE_ACCOUNT_PROVIDER_ID)) + .extend(storedLoginBounds) + .strict() + +type DatabaseConnection = + | z.output + | z.output + | z.output + +interface DatabaseConnectionSpec { + descriptor: DatabaseConnectionDescriptor + /** Discriminator stored inside the encrypted blob; never changes for a provider id. */ + secretType: string + schema: z.ZodType + /** + * Parses a submitted connection once and binds the probe to that parsed + * value. The probe runs `SELECT 1` through the block's own operation, so + * host pinning and TLS match runs. + */ + parse: (raw: unknown) => { + connection: DatabaseConnection + probe: (signal: AbortSignal) => Promise<{ rows: unknown[] }> + } +} + +/** Binds a provider's schema to its probe, so the probe only ever sees that schema's output. */ +function defineSpec( + providerId: DatabaseConnectionProviderId, + secretType: string, + schema: z.ZodType, + probe: (connection: Connection, signal: AbortSignal) => Promise<{ rows: unknown[] }> +): DatabaseConnectionSpec { + return { + descriptor: DATABASE_CONNECTION_DESCRIPTORS[providerId], + secretType, + schema, + parse: (raw) => { + const connection = schema.parse(raw) + return { connection, probe: (signal) => probe(connection, signal) } + }, + } +} + +const SPECS: Record = { + [POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID]: defineSpec( + POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, + 'postgresql_connection_v1', + postgresqlStoredConnectionSchema, + async (connection, signal) => { + const { executePostgresqlQuery } = await import('@/lib/internal/postgresql/operations') + return executePostgresqlQuery({ ...connection, query: VERIFY_QUERY }, signal) + } + ), + [MYSQL_SERVICE_ACCOUNT_PROVIDER_ID]: defineSpec( + MYSQL_SERVICE_ACCOUNT_PROVIDER_ID, + 'mysql_connection_v1', + mysqlStoredConnectionSchema, + async (connection, signal) => { + const { executeMysqlQuery } = await import('@/lib/internal/mysql/operations') + return executeMysqlQuery({ ...connection, query: VERIFY_QUERY }, signal) + } + ), + [MSSQL_SERVICE_ACCOUNT_PROVIDER_ID]: defineSpec( + MSSQL_SERVICE_ACCOUNT_PROVIDER_ID, + 'mssql_connection_v1', + mssqlStoredConnectionSchema, + async (connection, signal) => { + const { executeMssqlQuery } = await import('@/lib/internal/mssql/operations') + return executeMssqlQuery( + { + ...connection, + connectionTimeout: MSSQL_DEFAULT_CONNECTION_TIMEOUT_MS, + query: VERIFY_QUERY, + }, + signal + ) + } + ), +} + +function secretSchemaFor(spec: DatabaseConnectionSpec) { + return z.object({ type: z.literal(spec.secretType), connection: spec.schema }).strict() +} + +/** + * A submitted connection that could not be parsed or did not answer `SELECT 1`: + * the user's input is at fault, unlike a failure to encrypt a verified one. + */ +export class DatabaseConnectionVerificationError extends Error { + constructor(message: string) { + super(message) + this.name = 'DatabaseConnectionVerificationError' + } +} + +/** + * Parses a submitted connection, proves it with `SELECT 1` through the block's + * own guarded connection path (host validation and IP pinning included), and + * encrypts it only after the database answered. Driver errors are never + * surfaced: a server can echo anything, including the login it rejected. + * Input and verification failures throw `DatabaseConnectionVerificationError`; + * an encryption failure propagates unchanged. + */ +export async function verifyAndEncryptDatabaseConnection( + providerId: DatabaseConnectionProviderId, + serialized: string +) { + const spec = SPECS[providerId] + const { label } = spec.descriptor + if (Buffer.byteLength(serialized, 'utf8') > MAX_SERIALIZED_CONNECTION_BYTES) { + throw new DatabaseConnectionVerificationError(`${label} connection must be at most 64 KiB`) + } + let parsed: ReturnType + try { + parsed = spec.parse(JSON.parse(serialized)) + } catch { + throw new DatabaseConnectionVerificationError(`Invalid ${label} connection configuration`) + } + const { connection } = parsed + try { + const result = await parsed.probe(AbortSignal.timeout(VERIFY_TIMEOUT_MS)) + const [first] = result.rows + if (!isRecordLike(first) || String(first.sim_connection_ok) !== '1') { + throw new Error('Invalid verification response') + } + } catch { + throw new DatabaseConnectionVerificationError(`Could not verify the ${label} connection`) + } + const { encrypted } = await encryptSecret(JSON.stringify({ type: spec.secretType, connection })) + return { + encryptedServiceAccountKey: encrypted, + username: connection.username, + displayName: `${connection.username}@${connection.host}/${connection.database}`, + } +} + +/** + * Loads a saved connection only when the credential is a live connection of + * this provider owned by exactly `scope`. Every failure reads the same, so a + * caller cannot tell a foreign credential from a revoked or malformed one. + */ +async function getDatabaseConnection( + providerId: DatabaseConnectionProviderId, + credentialId: string, + scope: CredentialOwnerScope +): Promise { + const spec = SPECS[providerId] + const unavailable = () => new Error(`${spec.descriptor.label} connection is unavailable`) + let decrypted: string | null + try { + decrypted = await readOwnerScopedServiceAccountSecret(credentialId, providerId, scope) + } catch { + throw unavailable() + } + if (decrypted === null) throw unavailable() + try { + return secretSchemaFor(spec).parse(JSON.parse(decrypted)).connection + } catch { + throw unavailable() + } +} + +/** + * Wraps a SQL family's operation handler so it also runs on a saved + * connection. With no executor-authorized credential for this service the + * handler gets the request untouched, so inline connection fields behave + * exactly as before. With one, the stored connection replaces every + * connection field in the input before the operation parses it: a caller, + * block default, or model can never steer the host, login, or TLS settings of + * a shared credential. Nothing is cached; the operation opens and closes its + * own connection per call. + */ +export function withSavedDatabaseConnection( + providerId: DatabaseConnectionProviderId, + handler: InternalToolOperationHandler +): InternalToolOperationHandler { + const { service, label } = SPECS[providerId].descriptor + return async (request: InternalToolOperationCall) => { + const authorized = request.context.authorizedServiceAccountCredential + if (authorized?.service !== service) return handler(request) + + if (!isRecordLike(request.input)) { + return Response.json({ error: 'Invalid request data' }, { status: 400 }) + } + let connection: DatabaseConnection + try { + connection = await getDatabaseConnection(providerId, authorized.credentialId, { + workspaceId: request.context.workspaceId, + organizationId: request.context.organizationId, + }) + } catch { + request.signal?.throwIfAborted() + return Response.json({ error: `${label} connection is unavailable` }, { status: 403 }) + } + request.signal?.throwIfAborted() + return handler({ ...request, input: { ...request.input, ...connection } }) + } +} diff --git a/apps/sim/lib/credentials/databricks-service-account.server.test.ts b/apps/sim/lib/credentials/databricks-service-account.server.test.ts index 420689c5606..50a8fb721ab 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.test.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.test.ts @@ -175,6 +175,171 @@ describe('saving a keyless Databricks connection', () => { }) }) +describe('a keyless Databricks connection with its own audience', () => { + const HOST = 'dbc-1234abcd-5678.cloud.databricks.com' + const CLIENT_ID = '00000000-0000-0000-0000-000000000000' + const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' + const ACCOUNT_ID = '11111111-2222-3333-4444-555555555555' + const fetch = vi.fn() + const config = { authType: 'oidc_federation', host: HOST, clientId: CLIENT_ID } + const reconnectOwner = { + ...owner, + storedSecret: { type: 'databricks_credential_v1', config: { ...config, subject: SUBJECT } }, + } + + /** A federation policy that pins {@link SUBJECT} and lists only `audience`. */ + function policyTrusting(audience: string) { + hoisted.mint.mockImplementation( + async ({ subject, audience: aud }: { subject: string; audience?: string }) => + `token-for:${subject}:${aud}` + ) + fetch.mockImplementation(async (url: string, init: RequestInit) => { + if (url.endsWith('/oidc/v1/token')) { + const body = new URLSearchParams(String(init.body)) + return body.get('subject_token') === `token-for:${SUBJECT}:${audience}` + ? jsonResponse({ access_token: 'dbx-token' }) + : jsonResponse({ error: 'invalid_grant' }, 401) + } + return jsonResponse({ userName: 'sim-workflows' }) + }) + } + + function storedConfig(): Record { + const [plaintext] = encryptionMockFns.mockEncryptSecret.mock.calls[0] as [string] + return JSON.parse(plaintext).config + } + + function mintedAudiences(): unknown[] { + return hoisted.mint.mock.calls.map(([input]) => (input as { audience?: string }).audience) + } + + beforeEach(() => { + fetch.mockReset() + vi.stubGlobal('fetch', fetch) + hoisted.mint.mockClear() + encryptionMockFns.mockEncryptSecret.mockReset() + encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + }) + + it('verifies, probes, and stores the custom audience', async () => { + policyTrusting(ACCOUNT_ID) + + await expect( + verifyAndEncryptDatabricksCredential( + JSON.stringify({ ...config, audience: ` ${ACCOUNT_ID} ` }), + reconnectOwner + ) + ).resolves.toMatchObject({ identity: 'sim-workflows' }) + // The verification and the any-subject probe both carry the audience real runs use. + expect(hoisted.mint).toHaveBeenCalledTimes(2) + expect(mintedAudiences()).toEqual([ACCOUNT_ID, ACCOUNT_ID]) + expect(storedConfig()).toMatchObject({ audience: ACCOUNT_ID }) + }) + + it.each([ + ['no audience', {}], + ['the default audience', { audience: 'sim-databricks' }], + ])('mints the default and stores no audience when given %s', async (_case, audience) => { + policyTrusting('sim-databricks') + + await verifyAndEncryptDatabricksCredential( + JSON.stringify({ ...config, ...audience }), + reconnectOwner + ) + expect(mintedAudiences()).toEqual(['sim-databricks', 'sim-databricks']) + expect(storedConfig()).not.toHaveProperty('audience') + }) + + it('goes back to the default when a reconnect clears a custom audience', async () => { + policyTrusting('sim-databricks') + const customOwner = { + ...owner, + storedSecret: { + type: 'databricks_credential_v1', + config: { ...config, subject: SUBJECT, audience: ACCOUNT_ID }, + }, + } + + await verifyAndEncryptDatabricksCredential(JSON.stringify(config), customOwner) + expect(mintedAudiences()).toEqual(['sim-databricks', 'sim-databricks']) + expect(storedConfig()).not.toHaveProperty('audience') + }) + + it('refuses another cloud’s default audience before calling Databricks', async () => { + await expect( + verifyAndEncryptDatabricksCredential( + JSON.stringify({ ...config, audience: 'sts.amazonaws.com' }), + reconnectOwner + ) + ).rejects.toThrow(/belongs to another cloud/) + expect(fetch).not.toHaveBeenCalled() + expect(hoisted.mint).not.toHaveBeenCalled() + }) + + it('names the audience it sent when the policy does not trust it', async () => { + policyTrusting('sim-databricks') + + await expect( + verifyAndEncryptDatabricksCredential( + JSON.stringify({ ...config, audience: ACCOUNT_ID }), + reconnectOwner + ) + ).rejects.toThrow(ACCOUNT_ID) + }) +}) + +describe('getting a keyless Databricks token for a run', () => { + const fetch = vi.fn() + + function storeKeyless(extra: Record) { + resetDbChainMock() + queueTableRows(credential, [ + { + workspaceId: 'ws-1', + organizationId: null, + type: 'service_account', + providerId: DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, + revokedAt: null, + encryptedServiceAccountKey: 'ciphertext', + }, + ]) + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ + type: 'databricks_credential_v1', + config: { + authType: 'oidc_federation', + host: 'dbc-1234abcd-5678.cloud.databricks.com', + clientId: '00000000-0000-0000-0000-000000000000', + subject: 'sim:workspace:ws-1:credential:abcdef0123456789', + ...extra, + }, + }), + }) + } + + beforeEach(() => { + hoisted.mint.mockClear() + fetch.mockReset().mockResolvedValue(jsonResponse({ access_token: 'dbx-token' })) + vi.stubGlobal('fetch', fetch) + }) + + it('mints with the stored custom audience', async () => { + storeKeyless({ audience: 'acct-1234' }) + + await resolveDatabricksCredentialToken('cred-1') + expect(hoisted.mint).toHaveBeenCalledWith(expect.objectContaining({ audience: 'acct-1234' })) + }) + + it('mints exactly the old default for a credential saved before audiences were configurable', async () => { + storeKeyless({}) + + await resolveDatabricksCredentialToken('cred-1') + expect(hoisted.mint).toHaveBeenCalledWith( + expect.objectContaining({ audience: 'sim-databricks' }) + ) + }) +}) + describe('getting a Databricks token for a run', () => { const fetch = vi.fn() diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index 6281a15be41..9ac606acb4a 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -11,7 +11,12 @@ import { providerFailureCode, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' -import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + WORKLOAD_IDENTITY_AUDIENCES, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, MAX_WORKLOAD_IDENTITY_TOKEN_TTL_SECONDS, @@ -35,14 +40,16 @@ import { DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' * client ID and secret mint a workspace token per run. * - `oidc_federation`: no stored secret — Sim signs a short-lived OIDC token * for this credential's subject and exchanges it (RFC 8693) for a token of - * the service principal whose federation policy trusts it. + * the service principal whose federation policy trusts it. The token's `aud` + * is the credential's own `audience` when set (one the customer's federation + * policy lists, such as their account ID), else the Sim default. * * Every mode resolves server-side to a bearer token plus the workspace host * (`accessToken`, `domain`), which fill the tools' `apiKey` and `host`. */ const DATABRICKS_CREDENTIAL_SECRET_TYPE = 'databricks_credential_v1' as const -const DATABRICKS_WORKLOAD_IDENTITY_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.databricks +const DEFAULT_DATABRICKS_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.databricks const REQUEST_TIMEOUT_MS = 15_000 /** Token and identity responses are small; the cap keeps a misbehaving host from exhausting memory. */ @@ -79,6 +86,8 @@ const oidcShape = { host: hostSchema, clientId: clientIdSchema, subject: z.string().min(1).max(512), + /** Absent for the default audience, so those credentials keep the shape earlier releases read. */ + audience: storedWorkloadIdentityAudienceSchema, } const patSchema = z @@ -114,6 +123,7 @@ const submittedConfigSchema = z.discriminatedUnion('authType', [ ...oidcShape, subject: oidcShape.subject.optional(), subjectProof: z.string().min(1).max(512).optional(), + audience: workloadIdentityAudienceSchema('databricks').optional(), }) .strict(), ]) @@ -137,7 +147,14 @@ async function requestWorkspaceToken( host: string, body: URLSearchParams, signal: AbortSignal | undefined, - headers: Record = {} + { + headers = {}, + refusalDetail = '', + }: { + headers?: Record + /** Appended to a refusal, naming what the federation policy must match. */ + refusalDetail?: string + } = {} ): Promise { const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) const url = `${workspaceOrigin(host)}/oidc/v1/token` @@ -184,7 +201,7 @@ async function requestWorkspaceToken( ? payload.error : `HTTP ${response.status}` throw new DatabricksCredentialError( - `Databricks rejected the token request: ${reason}`, + `Databricks rejected the token request: ${reason}${refusalDetail}`, response.status ) } @@ -204,12 +221,13 @@ async function resolveDatabricksAccessToken( config.host, new URLSearchParams({ grant_type: 'client_credentials', scope: 'all-apis' }), signal, - { Authorization: `Basic ${basic}` } + { headers: { Authorization: `Basic ${basic}` } } ) } + const audience = config.audience ?? DEFAULT_DATABRICKS_AUDIENCE const subjectToken = await mintWorkloadIdentityToken({ subject: config.subject, - audience: DATABRICKS_WORKLOAD_IDENTITY_AUDIENCE, + audience, owner, // Databricks' token expires with this one, and Genie polls for up to five minutes. ttlSeconds: MAX_WORKLOAD_IDENTITY_TOKEN_TTL_SECONDS, @@ -223,19 +241,25 @@ async function resolveDatabricksAccessToken( grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', scope: 'all-apis', }), - signal + signal, + { refusalDetail: ` (the federation policy's audiences must include ${audience})` } ) } -/** The auth mode a decrypted Databricks secret holds, or undefined when it is not one. */ -export function storedDatabricksAuthType( - decrypted: string -): DatabricksCredentialConfig['authType'] | undefined { +/** The non-secret fields a reconnect keeps from a decrypted Databricks secret: its mode and custom audience. */ +export function storedDatabricksReconnectFields(decrypted: string): { + authType?: DatabricksCredentialConfig['authType'] + audience?: string +} { try { const parsed = storedSecretSchema.safeParse(JSON.parse(decrypted)) - return parsed.success ? parsed.data.config.authType : undefined + if (!parsed.success) return {} + const { config } = parsed.data + return config.authType === 'oidc_federation' && config.audience + ? { authType: config.authType, audience: config.audience } + : { authType: config.authType } } catch { - return undefined + return {} } } @@ -301,9 +325,10 @@ async function verifyWorkspaceIdentity( /** * Sim signs tokens for any service principal a member names, and every Sim - * tenant shares the issuer and audience, so a federation policy that matches on - * a claim other than `sub` (a `subject_claim` of `sim_workspace_id`, say) would - * let other Sim credentials act as the principal. Exchanging a subject no + * tenant shares the issuer and can claim any audience (by default the same one), + * so a federation policy that matches on a claim other than `sub` (a + * `subject_claim` of `sim_workspace_id`, say) would let other Sim credentials act + * as the principal. Exchanging a subject no * credential holds must be refused before the credential is saved. */ async function assertPolicyPinsSubject( @@ -356,8 +381,12 @@ export async function verifyAndEncryptDatabricksCredential( 'Use a personal access token or a service principal OAuth secret.' ) } - const { subject, subjectProof, ...rest } = submitted - config = { ...rest, subject: bindSubject(subject, subjectProof, owner) } + const { subject, subjectProof, audience, ...rest } = submitted + config = { + ...rest, + subject: bindSubject(subject, subjectProof, owner), + ...storedAudienceField(audience, DEFAULT_DATABRICKS_AUDIENCE), + } } else { config = submitted } diff --git a/apps/sim/lib/credentials/expiry-label.test.ts b/apps/sim/lib/credentials/expiry-label.test.ts new file mode 100644 index 00000000000..e46a409b495 --- /dev/null +++ b/apps/sim/lib/credentials/expiry-label.test.ts @@ -0,0 +1,21 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { savedTokenExpiryLabel } from '@/lib/credentials/expiry-label' + +const NOW = Date.parse('2026-11-11T12:00:00.000Z') +const DAY_MS = 24 * 60 * 60 * 1000 + +describe('saved token expiry caption', () => { + it.each([ + ['at the moment it expires', 0, 'expired'], + ['after it expired', -DAY_MS, 'expired'], + ['with a minute left', 60_000, 'expires in 1 day'], + ['with exactly a day left', DAY_MS, 'expires in 1 day'], + ['with just over a day left', DAY_MS + 1, 'expires in 2 days'], + ['with a month left', 30 * DAY_MS, 'expires in 30 days'], + ])('reads %s', (_, remainingMs, label) => { + expect(savedTokenExpiryLabel(new Date(NOW + remainingMs), NOW)).toBe(label) + }) +}) diff --git a/apps/sim/lib/credentials/expiry-label.ts b/apps/sim/lib/credentials/expiry-label.ts new file mode 100644 index 00000000000..4d4b31ace7a --- /dev/null +++ b/apps/sim/lib/credentials/expiry-label.ts @@ -0,0 +1,23 @@ +const DAY_MS = 24 * 60 * 60 * 1000 + +/** + * How long a saved token has left, as a lowercase caption: `expired` once the moment has come, + * otherwise the whole days left rounded up, so a token with an hour left still reads + * `expires in 1 day` rather than as already gone. Client-safe. + */ +export function savedTokenExpiryLabel(expiresAt: Date, now: number): string { + const remainingMs = expiresAt.getTime() - now + if (remainingMs <= 0) return 'expired' + const days = Math.ceil(remainingMs / DAY_MS) + return `expires in ${days} ${days === 1 ? 'day' : 'days'}` +} + +/** + * How long until {@link savedTokenExpiryLabel} reads differently: until the day count drops, or + * until the token expires on its last day. `null` once it reads `expired`, which never changes. + */ +export function msUntilSavedTokenExpiryLabelChanges(expiresAt: Date, now: number): number | null { + const remainingMs = expiresAt.getTime() - now + if (!(remainingMs > 0)) return null + return remainingMs - (Math.ceil(remainingMs / DAY_MS) - 1) * DAY_MS +} diff --git a/apps/sim/lib/credentials/gitlab-personal-token.test.ts b/apps/sim/lib/credentials/gitlab-personal-token.test.ts index 825d9851022..cc7d96f7882 100644 --- a/apps/sim/lib/credentials/gitlab-personal-token.test.ts +++ b/apps/sim/lib/credentials/gitlab-personal-token.test.ts @@ -92,6 +92,17 @@ describe('GitLab personal token verification', () => { .mockResolvedValueOnce(respond(details)) await expect(verifyGitLabPersonalToken('secret')).rejects.toThrow('api scope') }) + it.each([ + ['midnight UTC on its expiry date', '2027-03-01', new Date('2027-03-01T00:00:00.000Z')], + ['no expiry for a date that does not exist', '2027-02-30', null], + ['no expiry for a date already past', '2020-01-01', null], + ])('records %s', async (_, expiresAt, expected) => { + mocks.fetch + .mockReset() + .mockResolvedValueOnce(respond(user)) + .mockResolvedValueOnce(respond({ ...token, expires_at: expiresAt })) + expect((await verifyGitLabPersonalToken('secret')).expiresAt).toEqual(expected) + }) it('does not reflect provider error bodies or retry failed verification', async () => { mocks.fetch.mockReset().mockResolvedValue(respond({ secret: 'sensitive' }, 401)) await expect(verifyGitLabPersonalToken('secret')).rejects.toThrow('GitLab rejected this token') diff --git a/apps/sim/lib/credentials/gitlab-personal-token.ts b/apps/sim/lib/credentials/gitlab-personal-token.ts index a0ee529c7b0..209ddab7496 100644 --- a/apps/sim/lib/credentials/gitlab-personal-token.ts +++ b/apps/sim/lib/credentials/gitlab-personal-token.ts @@ -5,6 +5,7 @@ import { resourceScopeFromOwner, sameResourceScope } from '@/lib/core/resource-s import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { isHostNotFound } from '@/lib/credentials/token-service-accounts/errors' +import { credibleTokenExpiry, gitlabTokenExpiration } from '@/lib/credentials/vendor-token-expiry' import { normalizeGitLabHost } from '@/tools/gitlab/utils' const gitLabUserSchema = z.object({ @@ -99,7 +100,7 @@ export async function verifyGitLabPersonalToken(accessToken: string, rawHost?: s instanceUrl, displayName: `${user.data.name || user.data.username} (${new URL(instanceUrl).host})`, grantedScopes: token.data.scopes, - expiresAt: token.data.expires_at ? new Date(`${token.data.expires_at}T00:00:00Z`) : null, + expiresAt: credibleTokenExpiry(gitlabTokenExpiration(token.data.expires_at)) ?? null, } } diff --git a/apps/sim/lib/credentials/google-workload-identity.server.test.ts b/apps/sim/lib/credentials/google-workload-identity.server.test.ts index 88f8b8d8ca4..0361b6ebe1c 100644 --- a/apps/sim/lib/credentials/google-workload-identity.server.test.ts +++ b/apps/sim/lib/credentials/google-workload-identity.server.test.ts @@ -32,6 +32,24 @@ const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' const IMPERSONATE_URL = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(SA_EMAIL)}:generateAccessToken` const CLOUD_PLATFORM = 'https://www.googleapis.com/auth/cloud-platform' const BIGQUERY = 'https://www.googleapis.com/auth/bigquery' +const SIGN_JWT_URL = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(SA_EMAIL)}:signJwt` +const GMAIL_SCOPES = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', +] +const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar' +const DRIVE_SCOPES = [ + 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/drive', +] +const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive' +const DRIVE_READONLY = 'https://www.googleapis.com/auth/drive.readonly' +const GMAIL_PROFILE = 'https://gmail.googleapis.com/gmail/v1/users/me/profile' +const CALENDAR_LIST = 'https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1' +const DRIVE_ABOUT = 'https://www.googleapis.com/drive/v3/about?fields=user' +const WORKSPACE_READS = [GMAIL_PROFILE, CALENDAR_LIST, DRIVE_ABOUT] +const WORKSPACE_USER = 'alice@example.com' const owner = { workspaceId: 'ws-1', actorUserId: 'user-1' } const storedConfig = { @@ -64,28 +82,76 @@ function fakeGoogle( options: { acceptSubject?: (subject: string) => boolean canImpersonate?: (subject: string) => boolean + /** Who may call signJwt; defaults to `canImpersonate`. */ + canSign?: (subject: string) => boolean stsError?: Response iamError?: Response + /** Answers the OAuth token endpoint instead of issuing a delegated token. */ + tokenError?: Response + /** Answers the OAuth token endpoint's first request only. */ + firstTokenError?: Response + /** Answers a Workspace read instead of 200. */ + readError?: Response + /** The provider's allowed audiences; empty accepts only its own resource name, as Google does. */ + allowedAudiences?: string[] } = {} ) { const sts: RecordedRequest[] = [] const iam: RecordedRequest[] = [] + const signJwt: RecordedRequest[] = [] + const token: RecordedRequest[] = [] + const reads: RecordedRequest[] = [] fetch.mockImplementation(async (url: string, init: RequestInit = {}) => { + const headers = Object.fromEntries(new Headers(init.headers).entries()) const record = () => ({ url, - headers: Object.fromEntries(new Headers(init.headers).entries()), - body: init.body ? JSON.parse(String(init.body)) : {}, + headers, + body: + headers['content-type'] === 'application/x-www-form-urlencoded' + ? Object.fromEntries(new URLSearchParams(String(init.body))) + : init.body + ? JSON.parse(String(init.body)) + : {}, redirect: init.redirect, }) + if (url.endsWith(':signJwt')) { + const request = record() + signJwt.push(request) + const subject = (request.headers.authorization ?? '').replace('Bearer fed:', '') + const canSign = options.canSign ?? options.canImpersonate + if (canSign && !canSign(subject)) { + return jsonResponse( + { error: { code: 403, status: 'PERMISSION_DENIED', message: 'denied' } }, + 403 + ) + } + return jsonResponse({ keyId: 'key-1', signedJwt: `signed|${request.body.payload}` }) + } + if (url === 'https://oauth2.googleapis.com/token') { + const request = record() + token.push(request) + if (options.tokenError) return options.tokenError.clone() + if (options.firstTokenError && token.length === 1) return options.firstTokenError.clone() + const claims = JSON.parse(String(request.body.assertion).replace('signed|', '')) + return jsonResponse({ + access_token: `ya29.dwd-${claims.sub}-${claims.scope}`, + expires_in: 3599, + token_type: 'Bearer', + }) + } + if (WORKSPACE_READS.some((read) => url === read)) { + reads.push(record()) + return options.readError ? options.readError.clone() : jsonResponse({}) + } if (url === 'https://sts.googleapis.com/v1/token') { const request = record() sts.push(request) if (options.stsError) return options.stsError.clone() const [, audience, subject] = String(request.body.subjectToken).split('|') - if ( - audience !== `https://iam.googleapis.com/${PROVIDER_NAME}` || - options.acceptSubject?.(subject) === false - ) { + const audienceAccepted = options.allowedAudiences?.length + ? options.allowedAudiences.includes(audience) + : audience === `https://iam.googleapis.com/${PROVIDER_NAME}` + if (!audienceAccepted || options.acceptSubject?.(subject) === false) { return jsonResponse( { error: 'invalid_grant', error_description: 'The audience in ID Token does not match' }, 400 @@ -116,7 +182,7 @@ function fakeGoogle( } return jsonResponse({}, 404) }) - return { sts, iam } + return { sts, iam, signJwt, token, reads } } function save( @@ -313,7 +379,7 @@ describe('saving a keyless Google Cloud credential', () => { const error: Error = await save({}).catch((caught) => caught) expect(error.message).toMatch(/Google Cloud|service account/) expect(error.message).not.toContain(leaked) - expect(error.message).not.toContain('sim-oidc') + expect(error.message).not.toContain('sim-oidc|') expect(error.message).not.toContain('fed:') } }) @@ -358,8 +424,8 @@ describe('getting a keyless Google Cloud token for a run', () => { expect(fetch).not.toHaveBeenCalled() }) - it.each(['google-drive', 'gmail', 'google-sheets', undefined])( - 'refuses the Workspace service %s before contacting Google', + it.each(['google-drive', 'gmail', 'google-sheets', 'google-ads', undefined])( + 'refuses %s, which keyless credentials do not serve, before contacting Google', async (service) => { fakeGoogle(fetch) @@ -401,3 +467,655 @@ describe('getting a keyless Google Cloud token for a run', () => { }) }) }) + +/** + * Settles a resolution whose token exchange waits between retries, advancing + * fake timers until it does. + */ +async function settleRetrying(promise: Promise): Promise<{ value?: T; error?: unknown }> { + let outcome: { value?: T; error?: unknown } | undefined + promise.then( + (value) => { + outcome = { value } + }, + (error: unknown) => { + outcome = { error } + } + ) + for (let step = 0; !outcome && step < 200; step++) await vi.advanceTimersByTimeAsync(250) + if (!outcome) throw new Error('The resolution never settled') + return outcome +} + +function claimsOf(request: RecordedRequest | undefined): Record { + return JSON.parse(String(request?.body.payload)) +} + +describe('saving a keyless credential with domain-wide delegation', () => { + const fetch = vi.fn() + const delegation = { user: WORKSPACE_USER, services: ['gmail', 'google-calendar'] } + + beforeEach(() => { + fetch.mockReset() + vi.stubGlobal('fetch', fetch) + workloadIdentityConfigured.value = true + encryptionMockFns.mockEncryptSecret.mockReset() + encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + }) + + it('signs an assertion for the Workspace user per service, exchanges it, and reads as that user', async () => { + const google = fakeGoogle(fetch, { canImpersonate: (subject) => subject === SUBJECT }) + + await expect( + save({ delegation: { user: 'Alice@Example.com', services: ['google-calendar', 'gmail'] } }) + ).resolves.toMatchObject({ serviceAccountEmail: SA_EMAIL, delegation }) + + const own = google.signJwt.filter((r) => r.headers.authorization === `Bearer fed:${SUBJECT}`) + expect(own.map((r) => r.url)).toEqual([SIGN_JWT_URL, SIGN_JWT_URL]) + expect(own.every((r) => r.redirect === 'error')).toBe(true) + const [gmail, calendar] = own.map(claimsOf) + expect(gmail).toMatchObject({ + iss: SA_EMAIL, + sub: WORKSPACE_USER, + aud: 'https://oauth2.googleapis.com/token', + scope: GMAIL_SCOPES.join(' '), + }) + expect(calendar).toMatchObject({ sub: WORKSPACE_USER, scope: CALENDAR_SCOPE }) + expect(Number(gmail.exp) - Number(gmail.iat)).toBe(3600) + + expect(google.token).toHaveLength(2) + expect(google.token[0]).toMatchObject({ + redirect: 'error', + body: { + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: `signed|${own[0].body.payload}`, + }, + }) + expect(google.reads.map((r) => [r.url, r.headers.authorization])).toEqual([ + [GMAIL_PROFILE, `Bearer ya29.dwd-${WORKSPACE_USER}-${GMAIL_SCOPES.join(' ')}`], + [CALENDAR_LIST, `Bearer ya29.dwd-${WORKSPACE_USER}-${CALENDAR_SCOPE}`], + ]) + expect(encryptionMockFns.mockEncryptSecret).toHaveBeenCalledWith( + JSON.stringify({ + type: 'google_workload_identity_v1', + config: { ...storedConfig, delegation }, + }) + ) + }) + + it('checks Drive, Docs, and Sheets, which share scopes and a read, once', async () => { + const google = fakeGoogle(fetch, { canImpersonate: (subject) => subject === SUBJECT }) + + await save({ + delegation: { + user: WORKSPACE_USER, + services: ['google-drive', 'google-docs', 'google-sheets'], + }, + }) + + const own = google.signJwt.filter((r) => r.headers.authorization === `Bearer fed:${SUBJECT}`) + expect(own.map((r) => claimsOf(r).scope)).toEqual([DRIVE_SCOPES.join(' ')]) + expect(google.reads.map((r) => r.url)).toEqual([DRIVE_ABOUT]) + }) + + it('names the token-creator role when the subject may impersonate but not sign', async () => { + fakeGoogle(fetch, { + canImpersonate: (subject) => subject === SUBJECT, + canSign: () => false, + }) + + await expect(save({ delegation })).rejects.toThrow( + `Grant roles/iam.serviceAccountTokenCreator on the service account to principal://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL}/subject/${SUBJECT}` + ) + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + + it('refuses a service account another Sim subject can sign for', async () => { + fakeGoogle(fetch, { + canImpersonate: (subject) => subject === SUBJECT, + canSign: () => true, + }) + + await expect(save({ delegation })).rejects.toThrow( + /also trusts other Sim credentials.*roles\/iam\.serviceAccountTokenCreator/ + ) + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + + it('names the Admin console step and scopes when Workspace refuses the delegation', async () => { + const google = fakeGoogle(fetch, { + canImpersonate: (subject) => subject === SUBJECT, + tokenError: jsonResponse( + { error: 'unauthorized_client', error_description: 'Client is unauthorized ya29.LEAK' }, + 401 + ), + }) + + const error: Error = await save({ delegation }).catch((caught) => caught) + expect(error.message).toMatch(/domain-wide delegation/) + expect(error.message).toContain(GMAIL_SCOPES.join(',')) + expect(error.message).toContain('(unauthorized_client)') + expect(error.message).not.toContain('LEAK') + expect(error.message).not.toContain('signed|') + expect(google.reads).toHaveLength(0) + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + + it('names the Admin console step when Workspace denies a scope', async () => { + fakeGoogle(fetch, { + canImpersonate: (subject) => subject === SUBJECT, + tokenError: jsonResponse({ error: 'access_denied' }, 403), + }) + + const error: Error = await save({ delegation }).catch((caught) => caught) + expect(error.message).toContain('Manage Domain Wide Delegation') + expect(error.message).toContain('(access_denied)') + }) + + it.each([ + { code: 'disabled_client', copy: /service account .* is disabled/ }, + { code: 'deleted_client', copy: /service account .* was deleted/ }, + { code: 'admin_policy_enforced', copy: /admin policies/ }, + { code: 'invalid_client', copy: /Google refused the delegated token/ }, + { code: 'org_internal', copy: /Google refused the delegated token/ }, + ])('does not send the admin to authorize scopes for $code', async ({ code, copy }) => { + fakeGoogle(fetch, { + canImpersonate: (subject) => subject === SUBJECT, + tokenError: jsonResponse({ error: code }, 401), + }) + + const error: Error = await save({ delegation }).catch((caught) => caught) + expect(error.message).toMatch(copy) + expect(error.message).toContain(`(${code})`) + expect(error.message).not.toContain('Manage Domain Wide Delegation') + }) + + it('names the API to enable when a delegated read is refused', async () => { + fakeGoogle(fetch, { + canImpersonate: (subject) => subject === SUBJECT, + readError: jsonResponse({ error: { code: 403, status: 'PERMISSION_DENIED' } }, 403), + }) + + await expect(save({ delegation })).rejects.toThrow(/Gmail API/) + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + + it.each([ + { user: 'not-an-email', services: ['gmail'] }, + { user: `${'a'.repeat(250)}@example.com`, services: ['gmail'] }, + { user: WORKSPACE_USER, services: [] }, + { user: WORKSPACE_USER, services: ['google-forms'] }, + { user: WORKSPACE_USER, services: ['google-bigquery'] }, + { user: WORKSPACE_USER }, + { services: ['gmail'] }, + { user: WORKSPACE_USER, services: ['gmail'], scopes: ['https://mail.google.com/'] }, + ])('refuses the delegation %o without contacting Google', async (invalid) => { + fakeGoogle(fetch) + + await expect(save({ delegation: invalid })).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) +}) + +describe('getting a delegated Google Workspace token for a run', () => { + const fetch = vi.fn() + const secret = JSON.stringify({ + type: 'google_workload_identity_v1', + config: { ...storedConfig, delegation: { user: WORKSPACE_USER, services: ['gmail'] } }, + }) + + function resolve(overrides: Partial[0]>) { + return resolveGoogleWorkloadIdentityToken({ + credentialId: 'dwd-cred', + decrypted: secret, + secretVersion: 'v1', + owner: { workspaceId: 'ws-1' }, + scopes: GMAIL_SCOPES, + service: 'google-email', + ...overrides, + }) + } + + beforeEach(() => { + fetch.mockReset() + vi.stubGlobal('fetch', fetch) + }) + + it.each([ + { label: 'a tool’s provider id', service: 'google-email', credentialId: 'dwd-tool' }, + { label: 'a selector’s service id', service: 'gmail', credentialId: 'dwd-selector' }, + ])('acts as the delegated user for $label', async ({ service, credentialId }) => { + const google = fakeGoogle(fetch) + + await expect(resolve({ service, credentialId })).resolves.toBe( + `ya29.dwd-${WORKSPACE_USER}-${[...GMAIL_SCOPES].sort().join(' ')}` + ) + expect(claimsOf(google.signJwt[0])).toMatchObject({ iss: SA_EMAIL, sub: WORKSPACE_USER }) + expect(google.iam).toHaveLength(0) + expect(google.reads).toHaveLength(0) + }) + + it('accepts an Impersonated Account that names the delegated user in any case', async () => { + fakeGoogle(fetch) + + await expect( + resolve({ credentialId: 'dwd-same-user', impersonateEmail: 'ALICE@example.com' }) + ).resolves.toContain(WORKSPACE_USER) + }) + + it('accepts an Impersonated Account that names the delegated user with surrounding spaces', async () => { + fakeGoogle(fetch) + + await expect( + resolve({ credentialId: 'dwd-spaced-user', impersonateEmail: ' alice@example.com ' }) + ).resolves.toContain(WORKSPACE_USER) + }) + + it('serves the Gmail label picker with the Impersonated Account set, minting gmail.modify', async () => { + const google = fakeGoogle(fetch) + const gmailModify = 'https://www.googleapis.com/auth/gmail.modify' + + await expect( + resolve({ + credentialId: 'dwd-gmail-labels', + service: 'gmail', + impersonateEmail: WORKSPACE_USER, + scopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }) + ).resolves.toBe(`ya29.dwd-${WORKSPACE_USER}-${gmailModify}`) + expect(claimsOf(google.signJwt[0])).toMatchObject({ sub: WORKSPACE_USER, scope: gmailModify }) + }) + + it('serves the Calendar connector’s calendar.events.readonly, minting calendar', async () => { + const google = fakeGoogle(fetch) + const calendarSecret = JSON.stringify({ + type: 'google_workload_identity_v1', + config: { + ...storedConfig, + delegation: { user: WORKSPACE_USER, services: ['google-calendar'] }, + }, + }) + + await expect( + resolve({ + credentialId: 'dwd-calendar-connector', + decrypted: calendarSecret, + service: 'google-calendar', + scopes: ['https://www.googleapis.com/auth/calendar.events.readonly'], + }) + ).resolves.toBe(`ya29.dwd-${WORKSPACE_USER}-${CALENDAR_SCOPE}`) + expect(claimsOf(google.signJwt[0])).toMatchObject({ + sub: WORKSPACE_USER, + scope: CALENDAR_SCOPE, + }) + }) + + it('refuses another Workspace user before contacting Google', async () => { + fakeGoogle(fetch) + + await expect( + resolve({ credentialId: 'dwd-other-user', impersonateEmail: 'bob@example.com' }) + ).rejects.toMatchObject({ code: 'unsupported_use' }) + expect(fetch).not.toHaveBeenCalled() + }) + + it.each(['google-forms', undefined])( + 'refuses %s, which keyless delegation does not reach, before contacting Google', + async (service) => { + fakeGoogle(fetch) + + await expect( + resolve({ credentialId: `dwd-${service}`, service, scopes: GMAIL_SCOPES }) + ).rejects.toMatchObject({ code: 'unsupported_use' }) + expect(fetch).not.toHaveBeenCalled() + } + ) + + it.each(['google-calendar', 'google-drive'])( + 'refuses %s scopes a Gmail-only delegation does not authorize, before contacting Google', + async (service) => { + fakeGoogle(fetch) + + await expect( + resolve({ credentialId: `dwd-${service}`, service, scopes: [CALENDAR_SCOPE] }) + ).rejects.toMatchObject({ code: 'unsupported_use' }) + expect(fetch).not.toHaveBeenCalled() + } + ) + + it('authorizes by scope, as Google does: a Sheets delegation serves a Docs block', async () => { + const google = fakeGoogle(fetch) + const sheetsOnly = JSON.stringify({ + type: 'google_workload_identity_v1', + config: { + ...storedConfig, + delegation: { user: WORKSPACE_USER, services: ['google-sheets'] }, + }, + }) + + await expect( + resolve({ + credentialId: 'dwd-sheets-for-docs', + decrypted: sheetsOnly, + service: 'google-docs', + scopes: DRIVE_SCOPES, + }) + ).resolves.toContain(WORKSPACE_USER) + expect(google.signJwt).toHaveLength(1) + }) + + it('still mints a service account token for a Google Cloud service', async () => { + const google = fakeGoogle(fetch) + + await expect( + resolve({ credentialId: 'dwd-bq', service: 'google-bigquery', scopes: [BIGQUERY] }) + ).resolves.toBe(`ya29.sa-token-for-${BIGQUERY}`) + expect(google.signJwt).toHaveLength(0) + expect(google.iam[0]?.url).toBe(IMPERSONATE_URL) + }) + + it('reuses a delegated token per credential, user, and scopes', async () => { + const google = fakeGoogle(fetch) + await resolve({ credentialId: 'dwd-cache' }) + await resolve({ credentialId: 'dwd-cache', service: 'gmail' }) + expect(google.signJwt).toHaveLength(1) + + await resolve({ credentialId: 'dwd-cache', scopes: [GMAIL_SCOPES[1]] }) + expect(google.signJwt).toHaveLength(2) + + await resolve({ credentialId: 'dwd-cache', secretVersion: 'v2' }) + expect(google.signJwt).toHaveLength(3) + }) + + it('never answers a service account request with a delegated token of the same scopes', async () => { + fakeGoogle(fetch) + const delegated = await resolve({ credentialId: 'dwd-identity', scopes: [GMAIL_SCOPES[1]] }) + const own = await resolve({ + credentialId: 'dwd-identity', + service: 'vertex-ai', + scopes: [GMAIL_SCOPES[1]], + }) + + expect(delegated).toBe(`ya29.dwd-${WORKSPACE_USER}-${GMAIL_SCOPES[1]}`) + expect(own).toBe(`ya29.sa-token-for-${GMAIL_SCOPES[1]}`) + }) + + it.each([ + { label: 'a Gmail metadata-only scope', service: 'google-email', scope: 'gmail.metadata' }, + { + label: 'an admin-mode connector', + service: 'google-email', + scope: 'admin.directory.user.readonly', + }, + { label: 'a scope outside Google Workspace', service: 'gmail', scope: 'cloud-platform' }, + ])( + 'refuses $label whose scopes the delegation does not authorize, before contacting Google', + async ({ service, scope }) => { + fakeGoogle(fetch) + + await expect( + resolve({ + credentialId: `dwd-scope-${scope}`, + service, + scopes: [`https://www.googleapis.com/auth/${scope}`], + }) + ).rejects.toMatchObject({ code: 'unsupported_use' }) + expect(fetch).not.toHaveBeenCalled() + } + ) + + it('refuses a read-only scope whose read-write scope the delegation does not authorize', async () => { + fakeGoogle(fetch) + + await expect( + resolve({ + credentialId: 'dwd-gmail-drive-readonly', + service: 'google-drive', + scopes: [DRIVE_READONLY], + }) + ).rejects.toMatchObject({ code: 'unsupported_use' }) + expect(fetch).not.toHaveBeenCalled() + }) + + it.each(['google-docs', 'google-sheets'])( + 'serves the Drive file picker for a credential delegated only for %s, minting the authorized Drive scope', + async (delegated) => { + const google = fakeGoogle(fetch) + const docsOnly = JSON.stringify({ + type: 'google_workload_identity_v1', + config: { ...storedConfig, delegation: { user: WORKSPACE_USER, services: [delegated] } }, + }) + + await expect( + resolve({ + credentialId: `dwd-picker-${delegated}`, + decrypted: docsOnly, + service: 'google-drive', + scopes: [DRIVE_READONLY], + }) + ).resolves.toBe(`ya29.dwd-${WORKSPACE_USER}-${DRIVE_SCOPE}`) + expect(claimsOf(google.signJwt[0])).toMatchObject({ sub: WORKSPACE_USER, scope: DRIVE_SCOPE }) + } + ) + + it('retries a token endpoint outage the way a JSON key’s exchange does', async () => { + const google = fakeGoogle(fetch, { + firstTokenError: jsonResponse({ error: 'internal_failure' }, 503), + }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const { value } = await settleRetrying(resolve({ credentialId: 'dwd-retry' })) + expect(value).toContain(WORKSPACE_USER) + expect(google.token).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + { + label: 'a refused signature', + google: { canSign: () => false }, + code: 'invalid_credentials', + }, + { + label: 'Workspace refusing the client', + google: { tokenError: jsonResponse({ error: 'unauthorized_client' }, 401) }, + code: 'invalid_credentials', + }, + { + label: 'an unavailable token endpoint', + google: { tokenError: jsonResponse({ error: 'internal_failure' }, 503) }, + code: 'provider_unavailable', + }, + ])('reports $label in the shared service-account codes', async ({ label, google, code }) => { + fakeGoogle(fetch, google) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const { error } = await settleRetrying(resolve({ credentialId: `dwd-${label}` })) + expect(error).toMatchObject({ code }) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('reading a stored keyless secret', () => { + const fetch = vi.fn() + + beforeEach(() => { + fetch.mockReset() + vi.stubGlobal('fetch', fetch) + }) + + function resolveWith(decrypted: string, overrides: { service: string; scopes: string[] }) { + return resolveGoogleWorkloadIdentityToken({ + credentialId: `stored-${overrides.service}-${decrypted.length}`, + decrypted, + secretVersion: 'v1', + owner: { workspaceId: 'ws-1' }, + ...overrides, + }) + } + + const withLaterService = JSON.stringify({ + type: 'google_workload_identity_v1', + config: { + ...storedConfig, + delegation: { user: WORKSPACE_USER, services: ['gmail', 'google-keep'] }, + }, + }) + + it('keeps Google Cloud tokens working when a later release delegated a service this one does not know', async () => { + const google = fakeGoogle(fetch) + + await expect( + resolveWith(withLaterService, { service: 'google-bigquery', scopes: [BIGQUERY] }) + ).resolves.toBe(`ya29.sa-token-for-${BIGQUERY}`) + expect(google.iam[0]?.url).toBe(IMPERSONATE_URL) + }) + + it('still acts as the delegated user for the services it knows', async () => { + fakeGoogle(fetch) + + await expect( + resolveWith(withLaterService, { service: 'google-email', scopes: GMAIL_SCOPES }) + ).resolves.toContain(WORKSPACE_USER) + }) + + it('authorizes nothing for the service it does not know', async () => { + fakeGoogle(fetch) + + await expect( + resolveWith(withLaterService, { + service: 'google-keep', + scopes: ['https://www.googleapis.com/auth/keep'], + }) + ).rejects.toMatchObject({ code: 'unsupported_use' }) + expect(fetch).not.toHaveBeenCalled() + }) + + it.each([ + { label: 'invalid JSON', decrypted: '{' }, + { + label: 'a delegation field it does not know', + decrypted: JSON.stringify({ + type: 'google_workload_identity_v1', + config: { + ...storedConfig, + delegation: { user: WORKSPACE_USER, services: ['gmail'], onlyLabels: ['x'] }, + }, + }), + }, + ])('refuses $label in the shared service-account codes', async ({ decrypted }) => { + fakeGoogle(fetch) + + await expect( + resolveWith(decrypted, { service: 'google-bigquery', scopes: [BIGQUERY] }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + }) +}) + +describe('a keyless credential with its own audience', () => { + const fetch = vi.fn() + const CUSTOM_AUDIENCE = 'https://sim.example.com/acme-prod' + const customSecret = JSON.stringify({ + type: 'google_workload_identity_v1', + config: { ...storedConfig, audience: CUSTOM_AUDIENCE }, + }) + + beforeEach(() => { + fetch.mockReset() + vi.stubGlobal('fetch', fetch) + workloadIdentityConfigured.value = true + encryptionMockFns.mockEncryptSecret.mockReset() + encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + }) + + it('signs the verification and the pin probe for that audience, still exchanging at the provider', async () => { + const google = fakeGoogle(fetch, { + allowedAudiences: [CUSTOM_AUDIENCE], + acceptSubject: (subject) => subject === SUBJECT, + }) + + await expect(save({ audience: ` ${CUSTOM_AUDIENCE} ` })).resolves.toMatchObject({ + serviceAccountEmail: SA_EMAIL, + }) + expect(google.sts.length).toBeGreaterThanOrEqual(2) + for (const request of google.sts) { + expect(String(request.body.subjectToken).split('|')[1]).toBe(CUSTOM_AUDIENCE) + expect(request.body.audience).toBe(`//iam.googleapis.com/${PROVIDER_NAME}`) + } + expect(google.sts.some((request) => !String(request.body.subjectToken).endsWith(SUBJECT))).toBe( + true + ) + expect(encryptionMockFns.mockEncryptSecret).toHaveBeenCalledWith( + JSON.stringify({ + type: 'google_workload_identity_v1', + config: { ...storedConfig, audience: CUSTOM_AUDIENCE }, + }) + ) + }) + + it('refuses the pin when a stray subject is accepted for that audience', async () => { + fakeGoogle(fetch, { allowedAudiences: [CUSTOM_AUDIENCE] }) + + await expect(save({ audience: CUSTOM_AUDIENCE })).rejects.toThrow( + /also trusts other Sim credentials/ + ) + }) + + it.each([`https://iam.googleapis.com/${PROVIDER_NAME}`, `//iam.googleapis.com/${PROVIDER_NAME}`])( + 'stores no audience when %s, the provider’s own name, is submitted', + async (audience) => { + fakeGoogle(fetch, { acceptSubject: (subject) => subject === SUBJECT }) + + await expect(save({ audience })).resolves.toMatchObject({ serviceAccountEmail: SA_EMAIL }) + expect(encryptionMockFns.mockEncryptSecret).toHaveBeenCalledWith( + JSON.stringify({ type: 'google_workload_identity_v1', config: storedConfig }) + ) + } + ) + + it('refuses another cloud’s default audience without contacting Google', async () => { + fakeGoogle(fetch) + + await expect(save({ audience: 'sts.amazonaws.com' })).rejects.toThrow( + /belongs to another cloud/ + ) + expect(fetch).not.toHaveBeenCalled() + }) + + it('names the custom audience to allow when STS refuses the token', async () => { + fakeGoogle(fetch) + + await expect(save({ audience: CUSTOM_AUDIENCE })).rejects.toThrow( + `with ${CUSTOM_AUDIENCE} in its allowed audiences` + ) + }) + + it('names the provider’s own name when STS refuses a default-audience token', async () => { + fakeGoogle(fetch, { allowedAudiences: [CUSTOM_AUDIENCE] }) + + await expect(save({})).rejects.toThrow( + `with no allowed audiences, or with https://iam.googleapis.com/${PROVIDER_NAME} among them` + ) + }) + + it('mints run tokens for the stored audience', async () => { + const google = fakeGoogle(fetch, { allowedAudiences: [CUSTOM_AUDIENCE] }) + + await expect( + resolveGoogleWorkloadIdentityToken({ + credentialId: 'cred-custom-audience', + decrypted: customSecret, + secretVersion: 'v1', + owner: { workspaceId: 'ws-1' }, + scopes: [BIGQUERY], + service: 'google-bigquery', + }) + ).resolves.toBe(`ya29.sa-token-for-${BIGQUERY}`) + expect(google.sts[0]?.body.subjectToken).toBe(`sim-oidc|${CUSTOM_AUDIENCE}|${SUBJECT}`) + }) +}) diff --git a/apps/sim/lib/credentials/google-workload-identity.server.ts b/apps/sim/lib/credentials/google-workload-identity.server.ts index c3ee029d898..1d7ef75254e 100644 --- a/apps/sim/lib/credentials/google-workload-identity.server.ts +++ b/apps/sim/lib/credentials/google-workload-identity.server.ts @@ -1,15 +1,23 @@ import { getErrorMessage } from '@sim/utils/errors' +import { toRecord } from '@sim/utils/object' import { LRUCache } from 'lru-cache' import { z } from 'zod' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { encryptSecret } from '@/lib/core/security/encryption' import { + GOOGLE_DELEGATION_SERVICES, GOOGLE_KEYLESS_SERVICES, GOOGLE_WORKLOAD_IDENTITY_SECRET_TYPE, + type GoogleDelegationService, + googleDelegationScopes, + googleDelegationScopesFor, + googleDelegationServiceFor, googleStsAudience, googleWorkloadIdentityAudience, googleWorkloadIdentityPrincipal, googleWorkloadIdentityProviderName, + googleWorkloadIdentityRole, + isGoogleDelegationUser, isGoogleProjectNumber, isGoogleServiceAccountEmail, isGoogleWorkloadIdentityId, @@ -19,6 +27,11 @@ import { providerFailureCode, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, mintWorkloadIdentityToken, @@ -30,6 +43,11 @@ import { type WorkloadIdentityCredentialOwner, WorkloadIdentityNotConfiguredError, } from '@/lib/credentials/workload-identity/subject' +import { + exchangeGoogleServiceAccountJwt, + GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES, +} from '@/lib/oauth/google-service-account-transport' +import { readWriteScopeOf } from '@/lib/oauth/utils' /** * Keyless Google Cloud credentials: Workload Identity Federation with service @@ -43,17 +61,28 @@ import { * (the service account and its IAM roles) that JSON-key credentials use, so * BigQuery and Vertex AI behave exactly as they do with a key. * + * With domain-wide delegation the federated token instead asks IAM to sign a + * JWT-bearer assertion for the service account with `sub` set to the delegated + * Workspace user, and Google's OAuth token endpoint exchanges it: the same + * assertion a JSON key signs locally, signed by a Google-managed key instead. + * * @see https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-providers * @see https://docs.cloud.google.com/iam/docs/reference/sts/rest/v1/TopLevel/token * @see https://docs.cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken + * @see https://docs.cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt + * @see https://developers.google.com/identity/protocols/oauth2/service-account */ const SUBMITTED_AUTH_TYPE = 'workload_identity' as const const STS_TOKEN_URL = 'https://sts.googleapis.com/v1/token' const IAM_CREDENTIALS_ORIGIN = 'https://iamcredentials.googleapis.com' +/** The JWT-bearer `aud`, which Google documents as always this URL. */ +const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token' const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform' const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange' +/** A JWT-bearer assertion's `exp` may be at most one hour after its `iat`. */ +const ASSERTION_LIFETIME_SECONDS = 3600 const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token' const JWT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:jwt' /** generateAccessToken's default and, without an organization policy change, its maximum. */ @@ -66,6 +95,28 @@ const MAX_GOOGLE_SUBJECT_BYTES = 127 const TOKEN_REUSE_MARGIN_MS = 5 * 60 * 1000 const TOKEN_CACHE_MAX = 10_000 +/** + * A cheap read per delegated service, made with the delegated token when the + * credential is saved: it proves the API is enabled for the service account's + * project and the user can use it. Docs and Sheets share Drive's scopes and are + * checked through the Drive API only: their own APIs have no call that needs no + * file, so those APIs being enabled shows up only in a run. + */ +const DRIVE_READ = { + api: 'Google Drive API', + url: 'https://www.googleapis.com/drive/v3/about?fields=user', +} +const DELEGATION_READS: Record = { + gmail: { api: 'Gmail API', url: 'https://gmail.googleapis.com/gmail/v1/users/me/profile' }, + 'google-drive': DRIVE_READ, + 'google-docs': DRIVE_READ, + 'google-sheets': DRIVE_READ, + 'google-calendar': { + api: 'Google Calendar API', + url: 'https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1', + }, +} + /** STS error codes safe to show; descriptions stay out of the message. */ const STS_DIAGNOSTIC_ERRORS = new Set([ 'invalid_grant', @@ -112,16 +163,75 @@ const subjectSchema = z .min(1) .refine((value) => Buffer.byteLength(value, 'utf8') <= MAX_GOOGLE_SUBJECT_BYTES) -const storedConfigSchema = z.object({ ...refShape, subject: subjectSchema }).strict() +const delegationUserSchema = z.string().refine(isGoogleDelegationUser) + +/** + * The stored delegation: one Workspace user, and the services whose scopes it + * authorizes. Services are read as plain strings so a service a later release + * adds leaves the credential readable here, where it authorizes nothing; any + * other unknown field still fails the parse, since it could narrow what the + * credential may do. + */ +const storedDelegationSchema = z + .object({ + user: delegationUserSchema, + services: z.array(z.string().min(1)).min(1), + }) + .strict() + +type StoredDelegation = z.infer + +/** The delegated services this release knows, in their canonical order. */ +function knownDelegatedServices(delegation: StoredDelegation): GoogleDelegationService[] { + return GOOGLE_DELEGATION_SERVICES.filter((service) => delegation.services.includes(service)) +} + +/** + * `delegation` and `audience` are absent on every credential saved without them, so + * their stored secret is unchanged and earlier releases' strict schema still reads it. + */ +const storedConfigSchema = z + .object({ + ...refShape, + subject: subjectSchema, + delegation: storedDelegationSchema.optional(), + /** A custom `aud` for Sim's token; absent means the provider's resource name. */ + audience: storedWorkloadIdentityAudienceSchema, + }) + .strict() type GoogleWorkloadIdentityConfig = z.infer +const submittedDelegationSchema = z + .object({ + user: z + .string() + .trim() + .toLowerCase() + .refine(isGoogleDelegationUser, 'Enter the email address of the Google Workspace user'), + services: z + .array( + z.enum(GOOGLE_DELEGATION_SERVICES, { + message: `Choose Google Workspace services from: ${GOOGLE_DELEGATION_SERVICES.join(', ')}`, + }) + ) + .min(1, 'Choose at least one Google Workspace service') + .transform((services) => + GOOGLE_DELEGATION_SERVICES.filter((service) => services.includes(service)) + ), + }) + .strict() + +type SubmittedDelegation = z.infer + const submittedConfigSchema = z .object({ authType: z.literal(SUBMITTED_AUTH_TYPE), ...refShape, subject: z.string().min(1).max(512).optional(), subjectProof: z.string().min(1).max(512).optional(), + delegation: submittedDelegationSchema.optional(), + audience: workloadIdentityAudienceSchema('google').optional(), }) .strict() @@ -177,28 +287,66 @@ async function postJson( return { status: response.status, payload: payload ?? {} } } +/** + * The HTTP status of a bearer-authorized GET, with the same redirect and + * timeout guards as {@link postJson}. The body is discarded unread. + * Undefined when Google could not be reached. + */ +async function getStatus(url: string, bearer: string): Promise { + try { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + void response.body?.cancel().catch(() => undefined) + return response.status + } catch { + return undefined + } +} + +function parsePayload(body: string): Record { + try { + return toRecord(JSON.parse(body)) + } catch { + return {} + } +} + function isProviderOutage(status: number): boolean { return providerFailureCode(status) === 'provider_unavailable' } -const STS_REFUSED_MESSAGE = - 'Google Cloud refused Sim’s token for this workload identity provider. Check the project number, pool ID, and provider ID; that the provider is enabled and is an OIDC provider for the issuer from Sim (the connect dialog or `sim credentials keyless-subject`), with no allowed audiences and the attribute mapping google.subject=assertion.sub; and that its attribute condition allows this credential’s subject.' +function stsRefusedMessage(config: GoogleWorkloadIdentityConfig): string { + const audiences = config.audience + ? `with ${config.audience} in its allowed audiences` + : `with no allowed audiences, or with ${googleWorkloadIdentityAudience(config)} among them` + return `Google Cloud refused Sim’s token for this workload identity provider. Check the project number, pool ID, and provider ID; that the provider is enabled and is an OIDC provider for the issuer from Sim (the connect dialog or \`sim credentials keyless-subject\`), ${audiences}, and the attribute mapping google.subject=assertion.sub; and that its attribute condition allows this credential’s subject.` +} /** Copy for an STS refusal. Only Google's documented issuer-connection error is matched by text. */ -function stsFailureMessage(status: number, payload: Record): string { +function stsFailureMessage( + status: number, + payload: Record, + config: GoogleWorkloadIdentityConfig +): string { if (isProviderOutage(status)) return 'Google Cloud STS is unavailable. Try again.' const description = typeof payload.error_description === 'string' ? payload.error_description : '' if (description.includes('Error connecting to the given credential')) { return 'Google Cloud could not fetch the signing keys from Sim’s issuer. The issuer must be reachable over public HTTPS.' } const code = typeof payload.error === 'string' ? payload.error : '' - return STS_DIAGNOSTIC_ERRORS.has(code) ? `${STS_REFUSED_MESSAGE} (${code})` : STS_REFUSED_MESSAGE + const message = stsRefusedMessage(config) + return STS_DIAGNOSTIC_ERRORS.has(code) ? `${message} (${code})` : message } /** - * Exchanges a Sim token for this subject at Google STS. The audience is the - * credential's own provider; the subject token's `aud` is that provider's - * resource name, which a provider with no allowed audiences accepts. + * Exchanges a Sim token for this subject at Google STS. The STS audience is + * always the credential's own provider. The subject token's `aud` is the + * credential's custom audience, which the provider must list in its allowed + * audiences, or else that provider's resource name, which a provider with no + * allowed audiences accepts. */ async function exchangeSubjectToken( config: GoogleWorkloadIdentityConfig, @@ -206,7 +354,7 @@ async function exchangeSubjectToken( ): Promise { const subjectToken = await mintWorkloadIdentityToken({ subject: config.subject, - audience: googleWorkloadIdentityAudience(config), + audience: config.audience ?? googleWorkloadIdentityAudience(config), owner, }) // Google asks that this request carry no Authorization header. @@ -223,7 +371,7 @@ async function exchangeSubjectToken( {} ) if (status !== 200) { - throw new GoogleWorkloadIdentityError(stsFailureMessage(status, payload), status) + throw new GoogleWorkloadIdentityError(stsFailureMessage(status, payload, config), status) } if (typeof payload.access_token !== 'string' || !payload.access_token) { throw new GoogleWorkloadIdentityError('Google Cloud STS answered without a token.', 502) @@ -231,15 +379,29 @@ async function exchangeSubjectToken( return payload.access_token } -function impersonationFailureMessage(status: number, config: GoogleWorkloadIdentityConfig) { +/** + * Copy for an IAM Credentials refusal. Signing needs a role the impersonation + * role lacks, so a refused signature always names the token-creator role. + */ +function iamFailureMessage( + status: number, + config: GoogleWorkloadIdentityConfig, + action: 'impersonate' | 'sign' +) { if (isProviderOutage(status)) return 'Google Cloud IAM is unavailable. Try again.' if (status === 403) { - return `The service account ${config.serviceAccountEmail} does not let this credential impersonate it. Grant roles/iam.workloadIdentityUser on the service account to ${googleWorkloadIdentityPrincipal(config, config.subject)}, and enable the IAM Service Account Credentials API. A new binding can take a few minutes to apply.` + const refusal = + action === 'sign' + ? 'does not let this credential sign for it, which domain-wide delegation needs' + : 'does not let this credential impersonate it' + const role = googleWorkloadIdentityRole(action === 'sign' || Boolean(config.delegation)) + return `The service account ${config.serviceAccountEmail} ${refusal}. Grant ${role} on the service account to ${googleWorkloadIdentityPrincipal(config, config.subject)}, and enable the IAM Service Account Credentials API. A new binding can take a few minutes to apply.` } if (status === 404) { return `Google Cloud could not find the service account ${config.serviceAccountEmail}. Check the email.` } - return `Google Cloud refused to impersonate ${config.serviceAccountEmail} (HTTP ${status}).` + const verb = action === 'sign' ? 'sign for' : 'impersonate' + return `Google Cloud refused to ${verb} ${config.serviceAccountEmail} (HTTP ${status}).` } interface ServiceAccountAccessToken { @@ -261,7 +423,7 @@ async function impersonateServiceAccount( { Authorization: `Bearer ${federatedToken}` } ) if (status !== 200) { - throw new GoogleWorkloadIdentityError(impersonationFailureMessage(status, config), status) + throw new GoogleWorkloadIdentityError(iamFailureMessage(status, config, 'impersonate'), status) } if (typeof payload.accessToken !== 'string' || !payload.accessToken) { throw new GoogleWorkloadIdentityError('Google Cloud IAM answered without a token.', 502) @@ -283,6 +445,155 @@ async function mintServiceAccountToken( return impersonateServiceAccount(config, federatedToken, scopes) } +/** + * Has IAM sign the JWT-bearer assertion for `user` with the service account's + * Google-managed key. Google requires `aud` to be its token URL and `exp` at + * most an hour after `iat`; signJwt allows up to 12 hours. + */ +async function signDelegationAssertion( + config: GoogleWorkloadIdentityConfig, + federatedToken: string, + user: string, + scopes: string[] +): Promise { + const issuedAt = Math.floor(Date.now() / 1000) + const claims = { + iss: config.serviceAccountEmail, + sub: user, + scope: scopes.join(' '), + aud: OAUTH_TOKEN_URL, + iat: issuedAt, + exp: issuedAt + ASSERTION_LIFETIME_SECONDS, + } + const url = `${IAM_CREDENTIALS_ORIGIN}/v1/projects/-/serviceAccounts/${encodeURIComponent(config.serviceAccountEmail)}:signJwt` + const { status, payload } = await postJson( + url, + { payload: JSON.stringify(claims) }, + { Authorization: `Bearer ${federatedToken}` } + ) + if (status !== 200) { + throw new GoogleWorkloadIdentityError(iamFailureMessage(status, config, 'sign'), status) + } + if (typeof payload.signedJwt !== 'string' || !payload.signedJwt) { + throw new GoogleWorkloadIdentityError('Google Cloud IAM answered without a signature.', 502) + } + return payload.signedJwt +} + +/** + * Copy for a refused delegated token, by Google's documented error code. Only + * `unauthorized_client` and `access_denied` mean the Admin console has not + * authorized the client ID for the scopes; the rest point elsewhere. + * @see https://developers.google.com/identity/protocols/oauth2/service-account#error-codes + */ +function delegationFailureMessage( + status: number, + payload: Record, + input: { serviceAccountEmail: string; user: string; scopes: string[] } +): string { + if (isProviderOutage(status)) return 'Google’s OAuth token endpoint is unavailable. Try again.' + const { serviceAccountEmail, user, scopes } = input + const code = typeof payload.error === 'string' ? payload.error : '' + const suffix = GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES.has(code) ? ` (${code})` : '' + switch (code) { + case 'invalid_grant': + return `Google refused to sign in as ${user}${suffix}. Check that the address belongs to an active user in your Google Workspace domain.` + case 'unauthorized_client': + case 'access_denied': + return `Google Workspace has not authorized this service account’s domain-wide delegation for ${user}${suffix}. In the Admin console, under Security, Access and data control, API controls, Manage Domain Wide Delegation, authorize the service account’s client ID (its numeric Unique ID) for these scopes: ${scopes.join(',')}. A new authorization can take a while to apply.` + case 'admin_policy_enforced': + return `Your Google Workspace admin policies block one or more of these scopes for this service account${suffix}. A Workspace administrator must allow its client ID under Security, Access and data control, API controls, third-party app access.` + case 'disabled_client': + return `Google reports the service account ${serviceAccountEmail} is disabled${suffix}. Enable it in Google Cloud under IAM & Admin, Service Accounts.` + case 'deleted_client': + return `Google reports the service account ${serviceAccountEmail} was deleted${suffix}. Restore it within 30 days of deletion, or connect another service account.` + default: + return `Google refused the delegated token for ${user}${suffix}. Check the service account’s configuration in Google Cloud and its domain-wide delegation in the Admin console.` + } +} + +/** + * Exchanges a signed assertion for an access token acting as the delegated + * user, through the same transport a JSON key's assertion takes: retries on + * 429 and 5xx within a budget, and a bounded read of the answer. + */ +async function exchangeDelegationAssertion( + assertion: string, + context: { serviceAccountEmail: string; user: string; scopes: string[] } +): Promise { + let response: Awaited> + try { + response = await exchangeGoogleServiceAccountJwt(OAUTH_TOKEN_URL, assertion) + } catch { + throw new GoogleWorkloadIdentityError('Could not reach Google. Try again.') + } + const payload = parsePayload(response.body) + if (!response.ok) { + throw new GoogleWorkloadIdentityError( + delegationFailureMessage(response.status, payload, context), + response.status + ) + } + if (typeof payload.access_token !== 'string' || !payload.access_token) { + throw new GoogleWorkloadIdentityError('Google answered without a token.', 502) + } + const expiresInSeconds = typeof payload.expires_in === 'number' ? payload.expires_in : 3600 + return { accessToken: payload.access_token, expiresAtMs: Date.now() + expiresInSeconds * 1000 } +} + +async function mintDelegatedToken( + config: GoogleWorkloadIdentityConfig, + federatedToken: string, + user: string, + scopes: string[] +): Promise { + const assertion = await signDelegationAssertion(config, federatedToken, user, scopes) + return exchangeDelegationAssertion(assertion, { + serviceAccountEmail: config.serviceAccountEmail, + user, + scopes, + }) +} + +/** + * Reads one cheap resource per service as the delegated user, with that + * service's token. Services with the same scopes and read (Drive, Docs, and + * Sheets) are checked once. + */ +async function verifyDelegation( + config: GoogleWorkloadIdentityConfig, + delegation: SubmittedDelegation, + owner: WorkloadIdentityOwner +): Promise { + const checks = new Map() + for (const service of delegation.services) { + const scopes = googleDelegationScopes(service) + const read = DELEGATION_READS[service] + checks.set(`${[...scopes].sort().join(' ')}|${read.url}`, { scopes, read }) + } + const federatedToken = await exchangeSubjectToken(config, owner) + for (const { scopes, read } of checks.values()) { + const { accessToken } = await mintDelegatedToken( + config, + federatedToken, + delegation.user, + scopes + ) + const status = await getStatus(read.url, accessToken) + if (status === undefined) { + throw new GoogleWorkloadIdentityError(`Could not reach the ${read.api}. Try again.`) + } + if (status === 200) continue + if (isProviderOutage(status)) { + throw new GoogleWorkloadIdentityError(`The ${read.api} is unavailable. Try again.`, status) + } + throw new GoogleWorkloadIdentityError( + `Sim signed in as ${delegation.user}, but the ${read.api} refused the request (HTTP ${status}). Enable the ${read.api} in the project that owns ${config.serviceAccountEmail}, and check that ${delegation.user} can use the service.`, + status + ) + } +} + function isRefusal(error: unknown): boolean { return ( error instanceof GoogleWorkloadIdentityError && @@ -303,22 +614,107 @@ async function assertSubjectPinned( owner: WorkloadIdentityOwner ): Promise { const stray = { ...config, subject: newWorkloadIdentitySubject(owner) } - try { - await mintServiceAccountToken(stray, owner, [CLOUD_PLATFORM_SCOPE]) - } catch (error) { - if (isRefusal(error)) return - throw new GoogleWorkloadIdentityError( + const unconfirmed = () => + new GoogleWorkloadIdentityError( 'Sim could not confirm this service account trusts only this credential. Try again.' ) + /** Whether Google refused the stray subject this step. */ + const refused = async (step: () => Promise): Promise => { + try { + await step() + } catch (error) { + if (isRefusal(error)) return true + throw unconfirmed() + } + return false + } + + let federatedToken = '' + if ( + await refused(async () => { + federatedToken = await exchangeSubjectToken(stray, owner) + }) + ) { + return + } + // Delegation signs with signJwt, which the impersonation role does not grant, so a + // pool-wide grant of either permission must be caught. + const steps: Array<() => Promise> = [ + () => impersonateServiceAccount(stray, federatedToken, [CLOUD_PLATFORM_SCOPE]), + ] + const { delegation } = config + if (delegation) { + steps.push(() => + signDelegationAssertion( + stray, + federatedToken, + delegation.user, + googleDelegationScopesFor(knownDelegatedServices(delegation)) + ) + ) + } + for (const step of steps) { + if (!(await refused(step))) { + throw new GoogleWorkloadIdentityError( + `This service account also trusts other Sim credentials. Grant ${googleWorkloadIdentityRole(Boolean(delegation))} to this credential’s principal:// subject only (not a principalSet for the pool), and add the attribute condition from Sim (the connect dialog or \`sim credentials keyless-subject\`) so the provider accepts only this subject.` + ) + } } - throw new GoogleWorkloadIdentityError( - 'This service account also trusts other Sim credentials. Grant roles/iam.workloadIdentityUser to this credential’s principal:// subject only (not a principalSet for the pool), and add the attribute condition from Sim (the connect dialog or `sim credentials keyless-subject`) so the provider accepts only this subject.' - ) } -function readStoredSubject(storedSecret: Record | null | undefined) { +/** The configuration a stored keyless secret holds, or undefined for anything else. */ +function parseStoredConfig(storedSecret: unknown): GoogleWorkloadIdentityConfig | undefined { const parsed = storedSecretSchema.safeParse(storedSecret) - return parsed.success ? parsed.data.config.subject : undefined + return parsed.success ? parsed.data.config : undefined +} + +/** {@link parseStoredConfig} for the decrypted secret's JSON text. */ +function parseDecryptedConfig(decrypted: string): GoogleWorkloadIdentityConfig | undefined { + try { + return parseStoredConfig(JSON.parse(decrypted)) + } catch { + return undefined + } +} + +function readStoredSubject(storedSecret: Record | null | undefined) { + return parseStoredConfig(storedSecret)?.subject +} + +/** A keyless credential's domain-wide delegation, as a reconnect reopens on it. */ +export interface GoogleStoredDelegation { + user: string + services: GoogleDelegationService[] +} + +/** + * The non-secret fields a reconnect keeps, from one parse of the decrypted secret: + * its mode, the domain-wide delegation (Workspace user and the services this + * release knows), and a custom audience. A JSON key reads as `json_key` alone. + */ +export function storedGoogleReconnectFields(decrypted: string): { + authType: 'workload_identity' | 'json_key' + delegation?: GoogleStoredDelegation + audience?: string +} { + let secret: unknown + try { + secret = JSON.parse(decrypted) + } catch { + return { authType: 'json_key' } + } + if (toRecord(secret).type !== GOOGLE_WORKLOAD_IDENTITY_SECRET_TYPE) { + return { authType: 'json_key' } + } + const config = parseStoredConfig(secret) + const services = config?.delegation ? knownDelegatedServices(config.delegation) : [] + return { + authType: 'workload_identity', + ...(config?.delegation && services.length > 0 + ? { delegation: { user: config.delegation.user, services } } + : {}), + ...(config?.audience ? { audience: config.audience } : {}), + } } function bindSubject( @@ -342,12 +738,17 @@ export interface GoogleWorkloadIdentitySaveResult { workloadIdentityProvider: string /** A subject this save binds that the credential did not already hold. */ newSubject?: string + /** The verified domain-wide delegation, when the credential was saved with one. */ + delegation?: SubmittedDelegation } /** * Validates the submitted configuration, then proves it end to end: Sim's * token is exchanged at STS, the federated token impersonates the service - * account. Alongside, a subject no credential holds must be refused. Returns the encrypted blob. + * account, or, with domain-wide delegation, signs an assertion for the + * Workspace user that Google exchanges for each service, and a cheap read per + * service succeeds as that user. Alongside, a subject no credential holds must + * be refused. Returns the encrypted blob. */ export async function verifyAndEncryptGoogleWorkloadIdentityCredential( serialized: string, @@ -369,14 +770,25 @@ export async function verifyAndEncryptGoogleWorkloadIdentityCredential( providerId: submitted.providerId, serviceAccountEmail: submitted.serviceAccountEmail, subject: bindSubject(submitted.subject, submitted.subjectProof, owner), + ...(submitted.delegation ? { delegation: submitted.delegation } : {}), + ...storedAudienceField( + submitted.audience, + googleWorkloadIdentityAudience(submitted), + googleStsAudience(submitted) + ), } if (!subjectSchema.safeParse(config.subject).success) { throw new GoogleWorkloadIdentityError('This credential’s subject is too long for Google Cloud.') } // A token from generateAccessToken proves the provider accepted Sim's token and the - // service account lets this subject impersonate it. - const verify = () => mintServiceAccountToken(config, owner, [CLOUD_PLATFORM_SCOPE]) + // service account lets this subject impersonate it; a delegated read proves the rest + // of the delegation chain as well. + const { delegation } = submitted + const verify = () => + delegation + ? verifyDelegation(config, delegation, owner) + : mintServiceAccountToken(config, owner, [CLOUD_PLATFORM_SCOPE]) // The pin probe is independent of the verification, so both run at once; a // verification failure still reports first. const [verified, pinned] = await Promise.allSettled([ @@ -401,12 +813,15 @@ export async function verifyAndEncryptGoogleWorkloadIdentityCredential( ...(config.subject !== readStoredSubject(owner.storedSecret) ? { newSubject: config.subject } : {}), + ...(delegation ? { delegation } : {}), } } /** - * Minted tokens per credential, stored-secret version, and scopes: a reconnect - * re-encrypts the secret and so never reuses the old configuration's token. + * Minted tokens per credential, stored-secret version, delegated user (if any), + * and scopes: a reconnect (a changed audience included) re-encrypts the secret + * and so never reuses the old configuration's token, and a delegated token never + * answers for the service account's own. */ const tokenCache = new LRUCache({ max: TOKEN_CACHE_MAX }) @@ -418,32 +833,115 @@ export interface ResolveGoogleWorkloadIdentityTokenInput { secretVersion: string owner: WorkloadIdentityOwner scopes: string[] - /** A domain-wide-delegation subject, which a keyless credential refuses. */ + /** + * A domain-wide-delegation subject. Only a credential delegated for the + * service accepts one, and only its own delegated user. + */ impersonateEmail?: string /** The Google service the token is for, from server-side policy. */ service?: string } +function unsupportedUse(step: string, reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('unsupported_use', 400, { step, reason }) +} + +/** Reads the stored secret, or fails in the shared service-account codes. */ +function readStoredConfig(decrypted: string): GoogleWorkloadIdentityConfig { + const config = parseDecryptedConfig(decrypted) + if (!config) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'google_keyless_config', + reason: 'the stored keyless Google configuration could not be read; reconnect the credential', + }) + } + return config +} + +/** + * Read-only scopes whose read-write sibling is not a scope Google defines, by + * the authorized scope Google documents as covering them. `gmail.readonly` + * ("View your email messages and settings") is covered by `gmail.modify` + * ("Read, compose, and send emails"); the Gmail label picker asks for it. + * `calendar.events.readonly` ("View events on all your calendars") is covered + * by `calendar` ("See, edit, share, and permanently delete all the calendars + * you can access"), not by its `calendar.events` sibling, which the Calendar + * delegation does not authorize; the Calendar connector asks for it. + * @see https://developers.google.com/workspace/gmail/api/auth/scopes + * @see https://developers.google.com/workspace/calendar/api/auth + */ +const DELEGATED_READ_COVERS: ReadonlyMap = new Map([ + [ + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/gmail.modify', + ], + [ + 'https://www.googleapis.com/auth/calendar.events.readonly', + 'https://www.googleapis.com/auth/calendar', + ], +]) + +/** + * The scopes to sign a delegated assertion for, or undefined when the + * delegation does not authorize them. A delegation authorizes the scopes the + * connect dialog asked the Admin console to authorize for its services, and + * Google refuses an assertion naming any other scope. Authorization is by + * scope, as Google's is, not by the service asking: Drive, Docs, and Sheets + * share their scopes, so a delegation for one serves the others. A requested + * read-only scope is minted as the authorized scope that covers it: its + * read-write sibling (the Drive file picker asks for `drive.readonly`; `drive` + * is authorized), the same rule a connected account's granted scopes are + * checked by, or the scope {@link DELEGATED_READ_COVERS} names. + */ +function delegatedMintScopes( + delegation: StoredDelegation, + requested: readonly string[] +): string[] | undefined { + const authorized = new Set(googleDelegationScopesFor(knownDelegatedServices(delegation))) + const minted = new Set() + for (const scope of requested) { + const mint = [scope, readWriteScopeOf(scope), DELEGATED_READ_COVERS.get(scope)].find( + (candidate) => candidate !== undefined && authorized.has(candidate) + ) + if (!mint) return undefined + minted.add(mint) + } + return [...minted] +} + /** - * Resolves an authorized keyless Google credential to a service account token. - * It refuses domain-wide delegation and any service that is not a Google Cloud - * API before doing anything else: Workspace data needs a delegated user, which - * this mode cannot prove. + * Resolves an authorized keyless Google credential to an access token. A + * Google Cloud service gets a service account token. A delegable Google + * Workspace service gets a token acting as the delegated user, only when the + * credential was saved with domain-wide delegation whose scopes cover the + * request. Anything else is refused before Google is contacted. */ export async function resolveGoogleWorkloadIdentityToken( input: ResolveGoogleWorkloadIdentityTokenInput ): Promise { - if (input.impersonateEmail) { - throw new TokenServiceAccountValidationError('unsupported_use', 400, { - step: 'google_keyless_delegation', - reason: 'keyless Google Cloud credentials cannot impersonate a Google Workspace user', - }) - } - if (!input.service || !GOOGLE_KEYLESS_SERVICES.has(input.service)) { - throw new TokenServiceAccountValidationError('unsupported_use', 400, { - step: 'google_keyless_service', - reason: 'keyless Google Cloud credentials work only with Google Cloud APIs', - }) + const config = readStoredConfig(input.decrypted) + const delegation = googleDelegationServiceFor(input.service) ? config.delegation : undefined + + if (delegation) { + if (input.impersonateEmail && input.impersonateEmail.trim().toLowerCase() !== delegation.user) { + throw unsupportedUse( + 'google_keyless_delegation_user', + 'this keyless Google credential acts only as its delegated Workspace user' + ) + } + } else { + if (input.impersonateEmail) { + throw unsupportedUse( + 'google_keyless_delegation', + 'this keyless Google credential has no domain-wide delegation for this service' + ) + } + if (!input.service || !GOOGLE_KEYLESS_SERVICES.has(input.service)) { + throw unsupportedUse( + 'google_keyless_service', + 'this keyless Google credential works only with Google Cloud APIs and the Workspace services it was delegated' + ) + } } if (input.scopes.length === 0) { throw new TokenServiceAccountValidationError('invalid_credentials', 400, { @@ -451,24 +949,39 @@ export async function resolveGoogleWorkloadIdentityToken( reason: 'scopes are required for service account credentials', }) } - const scopeKey = [...new Set(input.scopes)].sort().join(' ') - const cacheKey = `${input.credentialId}:${input.secretVersion}:${scopeKey}` + const scopes = delegation ? delegatedMintScopes(delegation, input.scopes) : input.scopes + if (!scopes) { + throw unsupportedUse( + 'google_keyless_delegation_scope', + 'this keyless Google credential’s domain-wide delegation does not authorize the scopes this use needs' + ) + } + const scopeKey = [...new Set(scopes)].sort().join(' ') + const identityKey = delegation ? `delegated:${delegation.user}` : 'service-account' + const cacheKey = `${input.credentialId}:${input.secretVersion}:${identityKey}:${scopeKey}` const cached = tokenCache.get(cacheKey) if (cached) return cached // Concurrent block runs share one mint, so it is not tied to any one caller's signal. return coalesceLocally(`google-keyless:${cacheKey}`, async () => { - const { config } = storedSecretSchema.parse(JSON.parse(input.decrypted)) + const mintScopes = scopeKey.split(' ') let token: ServiceAccountAccessToken try { - token = await mintServiceAccountToken(config, input.owner, scopeKey.split(' ')) + token = delegation + ? await mintDelegatedToken( + config, + await exchangeSubjectToken(config, input.owner), + delegation.user, + mintScopes + ) + : await mintServiceAccountToken(config, input.owner, mintScopes) } catch (error) { if (!(error instanceof GoogleWorkloadIdentityError)) throw error // Report in the shared service-account codes: an outage is not a bad credential. throw new TokenServiceAccountValidationError( error.status === undefined ? 'provider_unavailable' : providerFailureCode(error.status), error.status ?? 502, - { step: 'google_keyless_token' } + { step: delegation ? 'google_keyless_delegated_token' : 'google_keyless_token' } ) } const ttl = token.expiresAtMs - Date.now() - TOKEN_REUSE_MARGIN_MS diff --git a/apps/sim/lib/credentials/google-workload-identity.test.ts b/apps/sim/lib/credentials/google-workload-identity.test.ts index 04e70bdccb3..19a66aa7009 100644 --- a/apps/sim/lib/credentials/google-workload-identity.test.ts +++ b/apps/sim/lib/credentials/google-workload-identity.test.ts @@ -2,10 +2,15 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { googleKeylessOffer } from '@/lib/credentials/google-workload-identity' +import { + googleDelegationServiceFor, + googleInitialDelegatedServices, + googleKeylessOffer, + googleWorkloadIdentitySetupCommands, +} from '@/lib/credentials/google-workload-identity' describe('keyless setup in the Google service account dialog', () => { - it.each(['google-drive', 'gmail', 'google-sheets'])( + it.each(['google-forms', 'google-contacts', 'google-tasks'])( 'never offers the keyless form when reconnecting a keyless credential from %s', (oauthServiceId) => { expect(googleKeylessOffer({ oauthServiceId, available: true, reconnectsKeyless: true })).toBe( @@ -14,12 +19,36 @@ describe('keyless setup in the Google service account dialog', () => { } ) - it('offers JSON keys only for a new connection from a Workspace service', () => { + it('offers JSON keys only for a new connection from a Workspace service it cannot delegate', () => { expect( - googleKeylessOffer({ oauthServiceId: 'gmail', available: true, reconnectsKeyless: false }) + googleKeylessOffer({ + oauthServiceId: 'google-forms', + available: true, + reconnectsKeyless: false, + }) ).toBe('none') }) + it.each(['gmail', 'google-drive', 'google-docs', 'google-sheets', 'google-calendar'])( + 'offers keyless with domain-wide delegation for %s, new or reconnected', + (oauthServiceId) => { + for (const reconnectsKeyless of [true, false]) { + expect(googleKeylessOffer({ oauthServiceId, available: true, reconnectsKeyless })).toBe( + 'offer' + ) + } + } + ) + + it.each([true, false])( + 'offers Gmail keyless from the Integrations page and chat card, which name its provider id (reconnect: %s)', + (reconnectsKeyless) => { + expect( + googleKeylessOffer({ oauthServiceId: 'google-email', available: true, reconnectsKeyless }) + ).toBe('offer') + } + ) + it.each([true, false])( 'offers keyless for BigQuery whether or not the reconnected credential is keyless (%s)', (reconnectsKeyless) => { @@ -39,12 +68,90 @@ describe('keyless setup in the Google service account dialog', () => { }) it('offers nothing keyless where this deployment cannot sign tokens', () => { - expect( - googleKeylessOffer({ - oauthServiceId: 'google-bigquery', - available: false, - reconnectsKeyless: true, - }) - ).toBe('none') + for (const oauthServiceId of ['google-bigquery', 'gmail']) { + expect( + googleKeylessOffer({ oauthServiceId, available: false, reconnectsKeyless: true }) + ).toBe('none') + } + }) +}) + +describe('the services a keyless dialog opens delegating', () => { + it('keeps every stored service when reconnected from the credential’s own page', () => { + expect(googleInitialDelegatedServices(undefined, ['google-calendar', 'gmail'])).toEqual([ + 'gmail', + 'google-calendar', + ]) + }) + + it('adds the service the dialog was opened for to the stored ones', () => { + expect(googleInitialDelegatedServices('google-docs', ['gmail'])).toEqual([ + 'gmail', + 'google-docs', + ]) + }) + + it('delegates Gmail when opened by its provider id', () => { + expect(googleInitialDelegatedServices('google-email', ['google-drive'])).toEqual([ + 'gmail', + 'google-drive', + ]) + }) + + it('delegates nothing when opened for a Google Cloud service with nothing stored', () => { + expect(googleInitialDelegatedServices('google-bigquery')).toEqual([]) + }) +}) + +describe('the delegated service a tool or selector names', () => { + it.each([ + ['google-email', 'gmail'], + ['gmail', 'gmail'], + ['google-drive', 'google-drive'], + ['google-calendar', 'google-calendar'], + ['google-bigquery', undefined], + ['google-forms', undefined], + [undefined, undefined], + ])('maps %s to %s', (service, expected) => { + expect(googleDelegationServiceFor(service)).toBe(expected) + }) +}) + +describe('keyless setup commands', () => { + const input = { + ref: { projectNumber: '123456789012', poolId: 'sim-pool', providerId: 'sim-oidc' }, + serviceAccountEmail: 'runner@analytics-prod.iam.gserviceaccount.com', + issuer: 'https://sim.example.com/oidc/workload', + subject: 'sim:workspace:ws-1:credential:abc', + } + + it('grants only impersonation without domain-wide delegation', () => { + const commands = googleWorkloadIdentitySetupCommands(input) + expect(commands).toContain('--role="roles/iam.workloadIdentityUser"') + expect(commands).not.toContain('serviceAccountTokenCreator') + }) + + it('grants the token creator role, which includes signJwt, with domain-wide delegation', () => { + const commands = googleWorkloadIdentitySetupCommands({ ...input, domainWideDelegation: true }) + expect(commands).toContain('--role="roles/iam.serviceAccountTokenCreator"') + expect(commands).toContain( + '--member="principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/sim-pool/subject/sim:workspace:ws-1:credential:abc"' + ) + }) + + it('lists a custom audience in the provider’s allowed audiences', () => { + const commands = googleWorkloadIdentitySetupCommands({ ...input, audience: 'acme-prod' }) + expect(commands).toContain('--allowed-audiences="acme-prod" \\') + }) + + it.each([ + undefined, + '', + 'https://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/sim-pool/providers/sim-oidc', + '//iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/sim-pool/providers/sim-oidc', + ])('sets no allowed audiences for the default audience (%s)', (audience) => { + expect(googleWorkloadIdentitySetupCommands({ ...input, audience })).not.toContain( + '--allowed-audiences' + ) }) }) diff --git a/apps/sim/lib/credentials/google-workload-identity.ts b/apps/sim/lib/credentials/google-workload-identity.ts index 42eeab2d59b..bef2a4d2f31 100644 --- a/apps/sim/lib/credentials/google-workload-identity.ts +++ b/apps/sim/lib/credentials/google-workload-identity.ts @@ -5,33 +5,131 @@ * token is exchanged at Google STS against the customer's workload identity * provider, and the federated token impersonates the service account. * - * Every value here ends up in a Google URL or audience, so each one is checked - * against Google's documented format and nothing else can reach the request. + * Every value here ends up in a Google URL, audience, or signed claim, so each + * one is checked against Google's documented format and nothing else can reach + * the request. + * + * A keyless credential may also opt into domain-wide delegation: it then acts + * as one Google Workspace user, for the Workspace services its admin chose. * * @see https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-providers + * @see https://knowledge.workspace.google.com/admin/apps/domain-wide-delegation-best-practices */ +import { impersonateEmailSchema } from '@/lib/api/contracts/oauth-connections' +import { storedAudienceField } from '@/lib/credentials/workload-identity/audiences' +import { getProviderIdFromServiceId, getScopesForService } from '@/lib/oauth/utils' + /** - * The Google services a keyless credential may mint for: Google Cloud APIs the - * service account calls as itself. Google Workspace services need domain-wide - * delegation, which keyless credentials do not support. + * The Google services a keyless credential may mint for as the service account + * itself: Google Cloud APIs. Google Workspace services need domain-wide + * delegation; see {@link GOOGLE_DELEGATION_SERVICES}. */ export const GOOGLE_KEYLESS_SERVICES: ReadonlySet = new Set([ 'google-bigquery', 'vertex-ai', ]) +/** + * The Google Workspace services a keyless credential can reach through + * domain-wide delegation, by OAuth service id. + */ +export const GOOGLE_DELEGATION_SERVICES = [ + 'gmail', + 'google-drive', + 'google-docs', + 'google-sheets', + 'google-calendar', +] as const + +export type GoogleDelegationService = (typeof GOOGLE_DELEGATION_SERVICES)[number] + +const DELEGATION_SERVICE_SET: ReadonlySet = new Set(GOOGLE_DELEGATION_SERVICES) + +function isGoogleDelegationService(value: string): value is GoogleDelegationService { + return DELEGATION_SERVICE_SET.has(value) +} + +/** + * Whether a keyless credential can serve this OAuth service or provider id: a + * Google Cloud service as the service account, or a Workspace service through + * delegation. + */ +export function isGoogleKeylessService(service: string): boolean { + return GOOGLE_KEYLESS_SERVICES.has(service) || googleDelegationServiceFor(service) !== undefined +} + +/** + * The delegated service a caller names. Tools and the Integrations page name + * the OAuth provider id (`google-email`); selectors and blocks name the + * service id (`gmail`). + */ +export function googleDelegationServiceFor( + service: string | undefined +): GoogleDelegationService | undefined { + if (!service) return undefined + if (isGoogleDelegationService(service)) return service + return GOOGLE_DELEGATION_SERVICES.find((id) => getProviderIdFromServiceId(id) === service) +} + +/** + * The services a keyless dialog opens delegating: a reconnected credential's + * stored services, so a reconnect never drops one, plus the delegable service + * the dialog was opened for. Anything this release does not know is left out. + */ +export function googleInitialDelegatedServices( + oauthServiceId: string | undefined, + storedServices: readonly string[] = [] +): GoogleDelegationService[] { + const services = new Set(storedServices) + const openedFor = googleDelegationServiceFor(oauthServiceId) + if (openedFor) services.add(openedFor) + return GOOGLE_DELEGATION_SERVICES.filter((service) => services.has(service)) +} + +/** + * User-identity scopes, which a service account can't be granted: Google + * rejects a service-account token request that includes them. + */ +export const GOOGLE_SERVICE_ACCOUNT_EXCLUDED_SCOPES: ReadonlySet = new Set([ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]) + +/** The scopes Sim requests for a delegated service, which the Admin console must authorize. */ +export function googleDelegationScopes(service: GoogleDelegationService): string[] { + return getScopesForService(service).filter( + (scope) => !GOOGLE_SERVICE_ACCOUNT_EXCLUDED_SCOPES.has(scope) + ) +} + +/** Every scope the given delegated services need, deduplicated and sorted. */ +export function googleDelegationScopesFor(services: readonly GoogleDelegationService[]): string[] { + return [...new Set(services.flatMap(googleDelegationScopes))].sort() +} + +/** RFC 5321's limit on a forward path, which a Workspace sign-in address can't exceed. */ +const MAX_DELEGATION_USER_LENGTH = 254 + +/** A Google Workspace user's email address, the `sub` a delegated token acts as. */ +export function isGoogleDelegationUser(value: string): boolean { + return ( + value.length <= MAX_DELEGATION_USER_LENGTH && impersonateEmailSchema.safeParse(value).success + ) +} + /** * What a Google service account connect dialog offers for keyless setup. * - * - `offer`: a Google Cloud service (new or reconnect), or a keyless credential - * reconnected from a context that names no service (its own detail page). - * - `cloud_only`: a keyless credential reconnected from a Google Workspace - * service, which can never use it; the dialog explains instead of saving. + * - `offer`: a Google Cloud service or a delegable Google Workspace service + * (new or reconnect), or a keyless credential reconnected from a context that + * names no service (its own detail page). + * - `cloud_only`: a keyless credential reconnected from another Google + * Workspace service, which can never use it; the dialog explains instead of saving. * - `none`: JSON key only. */ export function googleKeylessOffer(input: { - /** The OAuth service the dialog was opened for, when it was opened for one. */ + /** The OAuth service or provider id the dialog was opened for, when it was opened for one. */ oauthServiceId?: string /** This deployment can sign workload-identity tokens for this owner. */ available: boolean @@ -39,9 +137,10 @@ export function googleKeylessOffer(input: { reconnectsKeyless: boolean }): 'offer' | 'cloud_only' | 'none' { if (!input.available) return 'none' - if (input.oauthServiceId && GOOGLE_KEYLESS_SERVICES.has(input.oauthServiceId)) return 'offer' + const service = input.oauthServiceId + if (service && isGoogleKeylessService(service)) return 'offer' if (!input.reconnectsKeyless) return 'none' - return input.oauthServiceId ? 'cloud_only' : 'offer' + return service ? 'cloud_only' : 'offer' } /** The `type` of a keyless credential's stored secret; a JSON key's is `service_account`. */ @@ -151,19 +250,42 @@ export function googleWorkloadIdentityPrincipal( return `principal://iam.googleapis.com/projects/${ref.projectNumber}/locations/global/workloadIdentityPools/${ref.poolId}/subject/${subject}` } +/** + * The role this credential's subject needs on the service account. + * `roles/iam.workloadIdentityUser` grants `getAccessToken` but not `signJwt`, + * which domain-wide delegation signs its assertion with; + * `roles/iam.serviceAccountTokenCreator` grants both. + * @see https://docs.cloud.google.com/iam/docs/roles-permissions/iam + */ +export function googleWorkloadIdentityRole(domainWideDelegation: boolean): string { + return domainWideDelegation + ? 'roles/iam.serviceAccountTokenCreator' + : 'roles/iam.workloadIdentityUser' +} + /** * The gcloud commands that set up trust for one credential: the pool, an OIDC * provider for Sim's issuer that accepts only this credential's subject, and - * the binding that lets that subject impersonate the service account. The - * provider sets no allowed audiences, so it accepts its own resource name. + * the binding that lets that subject impersonate the service account (and, with + * domain-wide delegation, sign for it). Without a custom audience the provider + * sets no allowed audiences, so it accepts its own resource name; with one, it + * allows only that audience. */ export function googleWorkloadIdentitySetupCommands(input: { ref: GoogleWorkloadIdentityProviderRef serviceAccountEmail: string issuer: string subject: string + domainWideDelegation?: boolean + /** The audience Sim's tokens carry; the provider's own name or none sets no allowed audiences. */ + audience?: string }): string { const { ref, serviceAccountEmail, issuer, subject } = input + const { audience: customAudience } = storedAudienceField( + input.audience?.trim(), + googleWorkloadIdentityAudience(ref), + googleStsAudience(ref) + ) return [ `gcloud iam workload-identity-pools create ${ref.poolId} \\`, ` --project="${ref.projectNumber}" \\`, @@ -175,12 +297,13 @@ export function googleWorkloadIdentitySetupCommands(input: { ' --location="global" \\', ` --workload-identity-pool="${ref.poolId}" \\`, ` --issuer-uri="${issuer}" \\`, + ...(customAudience ? [` --allowed-audiences="${customAudience}" \\`] : []), ' --attribute-mapping="google.subject=assertion.sub" \\', ` --attribute-condition="assertion.sub=='${subject}'"`, '', `gcloud iam service-accounts add-iam-policy-binding ${serviceAccountEmail} \\`, ` --project="${ref.projectNumber}" \\`, - ' --role="roles/iam.workloadIdentityUser" \\', + ` --role="${googleWorkloadIdentityRole(input.domainWideDelegation ?? false)}" \\`, ` --member="${googleWorkloadIdentityPrincipal(ref, subject)}"`, ].join('\n') } diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts index 6ddcb05a15a..9fe5f1bed7c 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts @@ -25,7 +25,9 @@ vi.mock('@/lib/credentials/workload-identity/issuer', () => ({ import { credential } from '@sim/db/schema' import { + MicrosoftAppCredentialError, resolveMicrosoftAppCredentialToken, + resolveMicrosoftAppResourceToken, verifyAndEncryptMicrosoftAppCredential, } from '@/lib/credentials/microsoft-app-service-account.server' import { issueWorkloadIdentitySubject } from '@/lib/credentials/workload-identity/subject' @@ -133,7 +135,7 @@ function fakeMicrosoft( const tokenRequests: TokenRequest[] = [] const pendingErrors = [...(options.tokenErrorsFirst ?? [])] fetch.mockImplementation(async (url: string, init: RequestInit = {}) => { - if (url.startsWith('https://login.microsoftonline.com/')) { + if (url.endsWith('/oauth2/v2.0/token')) { const form = Object.fromEntries(new URLSearchParams(String(init.body))) tokenRequests.push({ url, form, redirect: init.redirect }) await options.tokenDelay?.() @@ -152,7 +154,7 @@ function fakeMicrosoft( access_token: `at:${form.scope}`, }) } - if (url.startsWith('https://graph.microsoft.com/')) { + if (url.includes('/v1.0/servicePrincipals(')) { return jsonResponse({ id: 'sp-object-id', appId: CLIENT_ID, displayName: 'Sim workflows' }) } if (url.endsWith('/api/data/v9.2/WhoAmI')) return jsonResponse({ UserId: 'app-user' }) @@ -321,6 +323,61 @@ describe('saving a Microsoft app credential', () => { /No federated credential on this app matches/ ) }) + + const CUSTOM_AUDIENCE = 'api://contoso-sim-exchange' + const storedConfig = () => + JSON.parse(String(encryptionMockFns.mockEncryptSecret.mock.calls[0]?.[0])).config + + it('verifies and probes the subject pin with a custom audience, and stores it', async () => { + const tokens = fakeMicrosoft(fetch, { + acceptAssertion: (assertion) => assertion === `sim-oidc|${CUSTOM_AUDIENCE}|${SUBJECT}`, + }) + + await save({ authType: 'federated', audience: ` ${CUSTOM_AUDIENCE} ` }, reconnectOwner) + + const assertions = tokens.map((request) => request.form.client_assertion) + expect(assertions).toHaveLength(2) + for (const assertion of assertions) { + expect(assertion.startsWith(`sim-oidc|${CUSTOM_AUDIENCE}|`)).toBe(true) + } + expect(storedConfig()).toMatchObject({ audience: CUSTOM_AUDIENCE, subject: SUBJECT }) + }) + + it('stores no audience when the default is submitted, so earlier releases still read it', async () => { + const tokens = fakeMicrosoft(fetch, { + acceptAssertion: (assertion) => assertion.endsWith(`|${SUBJECT}`), + }) + + await save({ authType: 'federated', audience: 'api://AzureADTokenExchange' }, reconnectOwner) + + expect(tokens.map((request) => request.form.client_assertion)).toContain( + `sim-oidc|api://AzureADTokenExchange|${SUBJECT}` + ) + expect(storedConfig()).toEqual({ + authType: 'federated', + tenantId: TENANT, + clientId: CLIENT_ID, + subject: SUBJECT, + }) + }) + + it('refuses another cloud’s default audience without contacting Entra', async () => { + fakeMicrosoft(fetch) + + await expect( + save({ authType: 'federated', audience: 'sts.amazonaws.com' }, reconnectOwner) + ).rejects.toThrow(/belongs to another cloud/) + expect(fetch).not.toHaveBeenCalled() + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + + it('names the audience it presented when no federated credential matches', async () => { + fakeMicrosoft(fetch, { acceptAssertion: () => false }) + + await expect( + save({ authType: 'federated', audience: CUSTOM_AUDIENCE }, reconnectOwner) + ).rejects.toThrow(CUSTOM_AUDIENCE) + }) }) it('verifies the Dataverse environment with its own token and WhoAmI', async () => { @@ -377,6 +434,80 @@ describe('saving a Microsoft app credential', () => { } ) + describe('in a national cloud', () => { + it('signs in at the cloud’s own Entra endpoint and reads the service principal from its Graph', async () => { + const tokens = fakeMicrosoft(fetch) + + await save({ authType: 'client_secret', clientSecret: SECRET, cloud: 'usgov_dod' }) + + expect(tokens.map(({ url, form }) => [url, form.scope])).toEqual([ + [ + `https://login.microsoftonline.us/${TENANT}/oauth2/v2.0/token`, + 'https://dod-graph.microsoft.us/.default', + ], + ]) + expect(fetch).toHaveBeenCalledWith( + `https://dod-graph.microsoft.us/v1.0/servicePrincipals(appId='${CLIENT_ID}')?$select=id,appId,displayName`, + expect.objectContaining({ redirect: 'error' }) + ) + expect(fetch.mock.calls.map(([url]) => String(url))).not.toContainEqual( + expect.stringContaining('microsoftonline.com') + ) + const stored = JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0]?.[0] ?? '{}') + expect(stored.config.cloud).toBe('usgov_dod') + }) + + it('addresses the certificate assertion to the cloud’s token endpoint', async () => { + const tokens = fakeMicrosoft(fetch) + + await save({ + authType: 'certificate', + certificate: TEST_CERTIFICATE, + privateKey: TEST_PRIVATE_KEY, + cloud: 'china', + }) + + const chinaTokenUrl = `https://login.partner.microsoftonline.cn/${TENANT}/oauth2/v2.0/token` + expect(tokens[0]?.url).toBe(chinaTokenUrl) + await expect( + jwtVerify(tokens[0]?.form.client_assertion ?? '', createPublicKey(TEST_CERTIFICATE), { + algorithms: ['PS256'], + audience: chinaTokenUrl, + }) + ).resolves.toBeDefined() + }) + + it('stores a global credential exactly as before, with no cloud field', async () => { + fakeMicrosoft(fetch) + + await save({ authType: 'client_secret', clientSecret: SECRET, cloud: 'global' }) + + const stored = JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0]?.[0] ?? '{}') + expect(stored.config).not.toHaveProperty('cloud') + }) + + it('refuses a Dataverse environment outside the global cloud before contacting Entra', async () => { + fakeMicrosoft(fetch) + + await expect( + save({ + authType: 'client_secret', + clientSecret: SECRET, + cloud: 'usgov', + environmentUrl: ENVIRONMENT, + }) + ).rejects.toThrow(/global cloud/) + expect(fetch).not.toHaveBeenCalled() + }) + + it('refuses a cloud Sim does not know', async () => { + await expect( + save({ authType: 'client_secret', clientSecret: SECRET, cloud: 'germany' }) + ).rejects.toThrow(MicrosoftAppCredentialError) + expect(fetch).not.toHaveBeenCalled() + }) + }) + it('never repeats the secret, key, or assertion in a refusal', async () => { fakeMicrosoft(fetch, { tokenError: jsonResponse( @@ -469,6 +600,165 @@ describe('getting a Microsoft app token for a run', () => { ]) }) + it('mints a Power BI token beside the Graph one, each cached under its own audience', async () => { + const tokens = fakeMicrosoft(fetch) + for (const service of ['microsoft-ad', 'microsoft-powerbi', 'microsoft-powerbi']) { + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-powerbi') + await resolveMicrosoftAppCredentialToken('cred-powerbi', service) + } + + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-powerbi') + await expect( + resolveMicrosoftAppCredentialToken('cred-powerbi', 'microsoft-powerbi') + ).resolves.toEqual({ accessToken: 'at:https://analysis.windows.net/powerbi/api/.default' }) + expect(tokens.map((request) => request.form.scope)).toEqual([ + 'https://graph.microsoft.com/.default', + 'https://analysis.windows.net/powerbi/api/.default', + ]) + }) + + it('refuses a national-cloud credential for Power BI, whose tools call the global service', async () => { + fakeMicrosoft(fetch) + storeCredential( + { authType: 'client_secret', clientSecret: SECRET, cloud: 'china' }, + 'ciphertext-powerbi-china' + ) + + await expect( + resolveMicrosoftAppCredentialToken('cred-powerbi-china', 'microsoft-powerbi') + ).rejects.toMatchObject({ code: 'cloud_mismatch', status: 400 }) + expect(fetch).not.toHaveBeenCalled() + }) + + describe('for a resource the tool operation chooses per call', () => { + const CLUSTER = 'https://help.westus.kusto.windows.net' + const adx = (resource: string, clouds: ('global' | 'usgov' | 'usgov_dod' | 'china')[]) => ({ + resource, + clouds, + serviceLabel: 'Azure Data Explorer', + }) + const RUN_SCOPE = { workspaceId: 'ws-1' } + + it('hands the executor the credential ID, not a token, when it authorizes the service', async () => { + fakeMicrosoft(fetch) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-adx-auth') + + await expect( + resolveMicrosoftAppCredentialToken('cred-adx', 'azure-data-explorer') + ).resolves.toEqual({ accessToken: 'cred-adx' }) + expect(fetch).not.toHaveBeenCalled() + }) + + it('authorizes the service without decrypting the stored secret', async () => { + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-adx-nokey') + encryptionMockFns.mockDecryptSecret + .mockReset() + .mockRejectedValue(new Error('key unavailable')) + + await expect( + resolveMicrosoftAppCredentialToken('cred-adx-nokey', 'azure-data-explorer') + ).resolves.toEqual({ accessToken: 'cred-adx-nokey' }) + }) + + it.each([ + ['another workspace', { workspaceId: 'ws-2' }], + ['no owner scope', {}], + ])( + 'refuses a credential for a run in %s, without decrypting or minting', + async (_label, scope) => { + fakeMicrosoft(fetch) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-adx-scope') + + await expect( + resolveMicrosoftAppResourceToken('cred-adx-scope', scope, adx(CLUSTER, ['global'])) + ).rejects.toThrow(/unavailable/) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + } + ) + + it('refuses to authorize an unavailable credential for it', async () => { + resetDbChainMock() + queueTableRows(credential, []) + + await expect( + resolveMicrosoftAppCredentialToken('cred-missing', 'azure-data-explorer') + ).rejects.toThrow(/unavailable/) + }) + + it('mints a token for each cluster audience, cached per cluster', async () => { + const tokens = fakeMicrosoft(fetch) + const other = 'https://other.eastus.kusto.windows.net' + for (const resource of [CLUSTER, other, CLUSTER]) { + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-adx') + await expect( + resolveMicrosoftAppResourceToken('cred-adx-mint', RUN_SCOPE, adx(resource, ['global'])) + ).resolves.toBe(`at:${resource}/.default`) + } + expect(tokens.map(({ url, form }) => [url, form.scope])).toEqual([ + [TOKEN_URL, `${CLUSTER}/.default`], + [TOKEN_URL, `${other}/.default`], + ]) + }) + + it('signs in at the national cloud the credential names', async () => { + const tokens = fakeMicrosoft(fetch) + storeCredential( + { authType: 'client_secret', clientSecret: SECRET, cloud: 'china' }, + 'ciphertext-adx-china' + ) + const cluster = 'https://mycluster.chinaeast2.kusto.chinacloudapi.cn' + + await resolveMicrosoftAppResourceToken('cred-adx-china', RUN_SCOPE, adx(cluster, ['china'])) + expect(tokens.map(({ url, form }) => [url, form.scope])).toEqual([ + [ + `https://login.partner.microsoftonline.cn/${TENANT}/oauth2/v2.0/token`, + `${cluster}/.default`, + ], + ]) + }) + + it('does not serve a cached cluster token to a call whose cluster is in no cloud of the credential', async () => { + const tokens = fakeMicrosoft(fetch) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-adx-mixed') + await resolveMicrosoftAppResourceToken('cred-adx-mixed', RUN_SCOPE, adx(CLUSTER, ['global'])) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-adx-mixed') + + await expect( + resolveMicrosoftAppResourceToken('cred-adx-mixed', RUN_SCOPE, adx(CLUSTER, [])) + ).rejects.toMatchObject({ code: 'cloud_mismatch' }) + expect(tokens).toHaveLength(1) + }) + + it('refuses a resource outside the credential’s cloud without minting', async () => { + fakeMicrosoft(fetch) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-adx-gov') + + await expect( + resolveMicrosoftAppResourceToken( + 'cred-adx-gov', + RUN_SCOPE, + adx('https://mycluster.usgovvirginia.kusto.usgovcloudapi.net', ['usgov', 'usgov_dod']) + ) + ).rejects.toMatchObject({ code: 'cloud_mismatch', status: 400 }) + expect(fetch).not.toHaveBeenCalled() + }) + + it.each([ + `${CLUSTER}/v1/rest/query`, + `${CLUSTER}:8443`, + 'http://help.westus.kusto.windows.net', + `${CLUSTER}/`, + 'help.westus.kusto.windows.net', + ])('refuses the audience %s before reading the credential', async (resource) => { + await expect( + resolveMicrosoftAppResourceToken('cred-adx-bad', RUN_SCOPE, adx(resource, ['global'])) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + }) + }) + it('refuses Dataverse for a credential saved without an environment', async () => { fakeMicrosoft(fetch) storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-graph-only') @@ -479,6 +769,19 @@ describe('getting a Microsoft app token for a run', () => { expect(fetch).not.toHaveBeenCalled() }) + it('serves a cached token without decrypting the stored secret again', async () => { + const tokens = fakeMicrosoft(fetch) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-hit') + await resolveMicrosoftAppCredentialToken('cred-hit', 'microsoft-ad') + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-hit') + encryptionMockFns.mockDecryptSecret.mockReset().mockRejectedValue(new Error('key unavailable')) + + await expect(resolveMicrosoftAppCredentialToken('cred-hit', 'sharepoint')).resolves.toEqual({ + accessToken: 'at:https://graph.microsoft.com/.default', + }) + expect(tokens).toHaveLength(1) + }) + it('reuses a minted Graph token until the stored secret changes', async () => { const tokens = fakeMicrosoft(fetch) storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-v1') @@ -494,6 +797,22 @@ describe('getting a Microsoft app token for a run', () => { expect(tokens[1]?.form.client_secret).toBe('rotated') }) + it.each(['microsoft-ad', 'sharepoint', 'microsoft-intune', 'microsoft-dataverse'])( + 'refuses a national-cloud credential for %s, whose tools call the global cloud, without minting', + async (service) => { + fakeMicrosoft(fetch) + storeCredential( + { authType: 'client_secret', clientSecret: SECRET, cloud: 'usgov' }, + `ciphertext-usgov-${service}` + ) + + await expect( + resolveMicrosoftAppCredentialToken(`cred-usgov-${service}`, service) + ).rejects.toMatchObject({ code: 'cloud_mismatch', status: 400 }) + expect(fetch).not.toHaveBeenCalled() + } + ) + it('asks for a reconnect once the stored certificate has expired, without contacting Entra', async () => { fakeMicrosoft(fetch) const expired = generateTestCertificate({ @@ -517,6 +836,26 @@ describe('getting a Microsoft app token for a run', () => { ).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 }) }) + describe('a keyless credential at run time', () => { + const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' + + it('presents the default audience, then the stored custom one, never a token cached for the old one', async () => { + const tokens = fakeMicrosoft(fetch) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-aud-before') + await resolveMicrosoftAppCredentialToken('cred-aud-change', 'microsoft-ad') + storeCredential( + { authType: 'federated', subject: SUBJECT, audience: 'api://contoso-sim' }, + 'ciphertext-aud-after' + ) + await resolveMicrosoftAppCredentialToken('cred-aud-change', 'microsoft-ad') + + expect(tokens.map((request) => request.form.client_assertion)).toEqual([ + `sim-oidc|api://AzureADTokenExchange|${SUBJECT}`, + `sim-oidc|api://contoso-sim|${SUBJECT}`, + ]) + }) + }) + describe('while a new federated credential propagates', () => { const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' const refusal = (code: number) => diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts index f1ded209f1a..9a56cb309da 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts @@ -5,18 +5,29 @@ import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { backoffWithJitter } from '@sim/utils/retry' +import { compareStrings } from '@sim/utils/string' import { eq } from 'drizzle-orm' import { SignJWT } from 'jose' import { LRUCache } from 'lru-cache' import { z } from 'zod' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + MICROSOFT_CLOUD_IDS, + MICROSOFT_CLOUDS, + type MicrosoftCloudId, +} from '@/lib/credentials/microsoft-clouds' import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config' import { providerFailureCode, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' -import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + WORKLOAD_IDENTITY_AUDIENCES, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, mintWorkloadIdentityToken, @@ -30,15 +41,22 @@ import { } from '@/lib/credentials/workload-identity/subject' import { normalizeMicrosoftDataverseEnvironmentUrl } from '@/lib/oauth/microsoft-dataverse' import { MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' /** * Saved Microsoft Entra app-only credentials (OAuth 2.0 client credentials). * * One app registration serves every Microsoft service that runs without a - * signed-in user: Microsoft Graph for Entra ID, SharePoint, and Intune, and the - * credential's own Dataverse environment for Dataverse and Dynamics 365. The - * app's application permissions and the environment's application user decide - * what it can do; Sim stores no permission list of its own. + * signed-in user: Microsoft Graph for Entra ID, SharePoint, and Intune, the + * Power BI REST API, the credential's own Dataverse environment for Dataverse + * and Dynamics 365, and the Azure Data Explorer cluster each call names. The + * app's application permissions, role assignments, and the environment's + * application user decide what it can do; Sim stores no permission list of its + * own. Each audience gets its own token. + * + * A credential signs in to one Microsoft cloud (`cloud`, absent for the global + * cloud); a national cloud has its own Entra and Graph endpoints, listed in + * `microsoft-clouds.ts`. * * - `client_secret`: the app's client secret. * - `certificate`: a PEM private key and its certificate. Sim signs a PS256 @@ -52,8 +70,6 @@ import { MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' */ const SECRET_TYPE = 'microsoft_app_credential_v1' as const -const LOGIN_ORIGIN = 'https://login.microsoftonline.com' -const GRAPH_RESOURCE = 'https://graph.microsoft.com' const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' const REQUEST_TIMEOUT_MS = 15_000 /** Microsoft asks for an assertion that expires "5-10 minutes after nbf at most". */ @@ -87,19 +103,6 @@ const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ const TENANT_DOMAIN_PATTERN = /^(?=.{4,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/ -/** - * The Microsoft services that may use this credential. Each one's `serviceAccountProviderId` in - * `oauth.ts` offers the credential in its picker; this list is what the resolver accepts. - */ -const MICROSOFT_APP_SERVICES = [ - 'microsoft-ad', - 'sharepoint', - 'microsoft-intune', - 'microsoft-dataverse', -] as const - -type MicrosoftAppService = (typeof MICROSOFT_APP_SERVICES)[number] - const tenantSchema = z .string() .trim() @@ -136,6 +139,8 @@ const baseShape = { tenantId: tenantSchema, clientId: clientIdSchema, environmentUrl: environmentUrlSchema.optional(), + /** Absent for the global cloud, so a global credential's stored shape never changes. */ + cloud: z.enum(MICROSOFT_CLOUD_IDS).optional(), } const pemSchema = (label: string) => @@ -166,6 +171,8 @@ const federatedShape = { ...baseShape, authType: z.literal('federated'), subject: z.string().min(1).max(512), + /** The `aud` Sim's token carries; absent for the default, so earlier releases still read it. */ + audience: storedWorkloadIdentityAudienceSchema, } const storedConfigSchema = z.discriminatedUnion('authType', [ @@ -184,6 +191,7 @@ const submittedConfigSchema = z.discriminatedUnion('authType', [ ...federatedShape, subject: federatedShape.subject.optional(), subjectProof: z.string().min(1).max(512).optional(), + audience: workloadIdentityAudienceSchema('microsoft').optional(), }) .strict(), ]) @@ -210,8 +218,13 @@ export class MicrosoftAppCredentialError extends Error { } } -const FEDERATION_MISMATCH_MESSAGE = - 'No federated credential on this app matches the issuer, subject, and audience Sim presents. Add it with the exact values from Sim (the connect dialog or `sim credentials keyless-subject`) (matching is case-sensitive). A new federated credential can take a few minutes to take effect.' +function federationMismatchMessage(config: MicrosoftAppConfig): string { + const audience = + config.authType === 'federated' + ? ` (${config.audience ?? WORKLOAD_IDENTITY_AUDIENCES.microsoft})` + : '' + return `No federated credential on this app matches the issuer, subject, and audience${audience} Sim presents. Add it with the exact values from Sim (the connect dialog or \`sim credentials keyless-subject\`) (matching is case-sensitive). A new federated credential can take a few minutes to take effect.` +} /** * Actionable copy for the Entra errors a person saving the credential can fix. @@ -243,10 +256,10 @@ function entraFailureMessage(aadsts: number | undefined, config: MicrosoftAppCon : 'Microsoft Entra does not recognize the requested resource in this tenant.' case 70021: case 700213: - return FEDERATION_MISMATCH_MESSAGE + return federationMismatchMessage(config) default: return config.authType === 'federated' - ? `Microsoft Entra refused the keyless sign-in${aadsts ? ` (AADSTS${aadsts})` : ''}. ${FEDERATION_MISMATCH_MESSAGE}` + ? `Microsoft Entra refused the keyless sign-in${aadsts ? ` (AADSTS${aadsts})` : ''}. ${federationMismatchMessage(config)}` : `Microsoft Entra refused the token request${aadsts ? ` (AADSTS${aadsts})` : ''}. Check the tenant ID, client ID, and credential.` } } @@ -322,8 +335,13 @@ function loadCertificateMaterial( } } -function tokenEndpoint(tenantId: string): string { - return `${LOGIN_ORIGIN}/${encodeURIComponent(tenantId)}/oauth2/v2.0/token` +function cloudOf(config: MicrosoftAppConfig) { + return MICROSOFT_CLOUDS[config.cloud ?? 'global'] +} + +/** The credential's Entra token endpoint: a fixed cloud origin plus the validated tenant. */ +function tokenEndpoint(config: MicrosoftAppConfig): string { + return `${cloudOf(config).loginOrigin}/${encodeURIComponent(config.tenantId)}/oauth2/v2.0/token` } /** The PS256 client assertion Microsoft documents for certificate credentials. */ @@ -336,7 +354,7 @@ async function signCertificateAssertion( .setProtectedHeader({ alg: 'PS256', typ: 'JWT', 'x5t#S256': thumbprintS256 }) .setIssuer(config.clientId) .setSubject(config.clientId) - .setAudience(tokenEndpoint(config.tenantId)) + .setAudience(tokenEndpoint(config)) .setJti(generateId()) .setIssuedAt(now) .setNotBefore(now) @@ -344,10 +362,13 @@ async function signCertificateAssertion( .sign(key) } -function mintFederatedAssertion(subject: string, owner: WorkloadIdentityOwner): Promise { +function mintFederatedAssertion( + config: Extract, + owner: WorkloadIdentityOwner +): Promise { return mintWorkloadIdentityToken({ - subject, - audience: WORKLOAD_IDENTITY_AUDIENCES.microsoft, + subject: config.subject, + audience: config.audience ?? WORKLOAD_IDENTITY_AUDIENCES.microsoft, owner, }) } @@ -360,7 +381,7 @@ async function clientAuthentication( const assertion = config.authType === 'certificate' ? await signCertificateAssertion(config) - : await mintFederatedAssertion(config.subject, owner) + : await mintFederatedAssertion(config, owner) return { client_assertion_type: CLIENT_ASSERTION_TYPE, client_assertion: assertion } } @@ -371,8 +392,9 @@ interface EntraToken { /** * Requests an app-only token for one resource (`/.default`) from the - * credential's own tenant. The token endpoint is fixed; only the tenant segment - * comes from the credential, and it was validated when the credential was saved. + * credential's own tenant. The token endpoint's origin is one of the fixed + * Microsoft clouds; only the tenant segment comes from the credential, and it + * was validated when the credential was saved. */ async function requestEntraToken( config: MicrosoftAppConfig, @@ -389,7 +411,7 @@ async function requestEntraToken( const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) let response: Response try { - response = await fetch(tokenEndpoint(config.tenantId), { + response = await fetch(tokenEndpoint(config), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body, @@ -451,7 +473,7 @@ async function getJson(url: string, token: string): Promise<{ status: number; bo */ async function readOwnServicePrincipal(config: MicrosoftAppConfig, token: string) { const { status, body } = await getJson( - `${GRAPH_RESOURCE}/v1.0/servicePrincipals(appId='${config.clientId}')?$select=id,appId,displayName`, + `${cloudOf(config).graphOrigin}/v1.0/servicePrincipals(appId='${config.clientId}')?$select=id,appId,displayName`, token ) if (status !== 200) { @@ -491,15 +513,27 @@ async function verifyDataverseApplicationUser(environmentUrl: string, token: str ) } -/** The auth mode a decrypted Microsoft app credential secret holds, or undefined when it is not one. */ -export function storedMicrosoftAppAuthType( - decrypted: string -): 'federated' | 'client_secret' | 'certificate' | undefined { +/** + * The auth mode, national cloud, and custom keyless audience a decrypted Microsoft app credential + * secret holds, for a reconnect to reopen on: empty when it is not one, no cloud for a global + * credential, and no audience for one that uses the default. + */ +export function storedMicrosoftAppReconnectFields(decrypted: string): { + authType?: MicrosoftAppConfig['authType'] + cloud?: MicrosoftCloudId + audience?: string +} { try { const parsed = storedSecretSchema.safeParse(JSON.parse(decrypted)) - return parsed.success ? parsed.data.config.authType : undefined + if (!parsed.success) return {} + const { config } = parsed.data + return { + authType: config.authType, + ...(config.cloud ? { cloud: config.cloud } : {}), + ...(config.authType === 'federated' && config.audience ? { audience: config.audience } : {}), + } } catch { - return undefined + return {} } } @@ -523,7 +557,7 @@ async function assertFederationPinsSubject( try { await requestEntraToken( { ...config, subject: newWorkloadIdentitySubject(owner) }, - GRAPH_RESOURCE, + cloudOf(config).graphOrigin, owner ) } catch (error) { @@ -581,23 +615,34 @@ export async function verifyAndEncryptMicrosoftAppCredential( 'Microsoft app credential' ) if (!parsed.success) throw new MicrosoftAppCredentialError(parsed.message) - const submitted = parsed.data + const { cloud, ...submitted } = parsed.data + if (cloud && cloud !== 'global' && submitted.environmentUrl) { + throw new MicrosoftAppCredentialError( + 'Dataverse environments are supported in the global cloud only. Remove the environment URL or choose the global cloud.' + ) + } + const cloudField = cloud && cloud !== 'global' ? { cloud } : {} let config: MicrosoftAppConfig if (submitted.authType === 'federated') { if (!(await isWorkloadIdentityConfigured())) { throw new WorkloadIdentityNotConfiguredError('Use a client secret or certificate.') } - const { subject, subjectProof, ...rest } = submitted - config = { ...rest, subject: bindSubject(subject, subjectProof, owner) } + const { subject, subjectProof, audience: submittedAudience, ...rest } = submitted + config = { + ...rest, + ...cloudField, + subject: bindSubject(subject, subjectProof, owner), + ...storedAudienceField(submittedAudience, WORKLOAD_IDENTITY_AUDIENCES.microsoft), + } } else { - config = submitted + config = { ...submitted, ...cloudField } } if (config.authType === 'certificate') { loadCertificateMaterial(config.privateKey, config.certificate) } const verify = async () => { - const graphToken = await requestEntraToken(config, GRAPH_RESOURCE, owner) + const graphToken = await requestEntraToken(config, cloudOf(config).graphOrigin, owner) const principal = await readOwnServicePrincipal(config, graphToken.accessToken) if (config.environmentUrl) { const dataverseToken = await requestEntraToken(config, config.environmentUrl, owner) @@ -661,46 +706,117 @@ async function requestRunEntraToken( } } -interface CachedToken { +export interface MicrosoftAppCredentialToken { accessToken: string + /** The credential's Dataverse environment, for Dataverse and Dynamics 365 tools. */ + instanceUrl?: string +} + +/** The token audience a service's tools need, and the host they read back from the credential. */ +interface MicrosoftAppResource { + resource: string instanceUrl?: string } /** - * Minted tokens per credential, resource, and stored-secret version: a - * reconnect re-encrypts the secret and so never reuses the old app's token. + * Where a token is for. `key` names the audience before the secret is decrypted, so a cached + * token is served without decrypting; it must decide `select`'s outcome for one stored-secret + * version. `select` checks the decrypted config and returns the resource on a cache miss. */ -const tokenCache = new LRUCache({ max: TOKEN_CACHE_MAX }) +interface MicrosoftAppAudience { + key: string + select: (config: MicrosoftAppConfig) => MicrosoftAppResource +} -export interface MicrosoftAppCredentialToken { - accessToken: string - /** The credential's Dataverse environment, for Dataverse and Dynamics 365 tools. */ - instanceUrl?: string +/** Refuses a credential whose cloud is not one a service's resource lives in. */ +function requireCloud( + config: MicrosoftAppConfig, + clouds: readonly MicrosoftCloudId[], + serviceLabel: string +) { + const cloud = config.cloud ?? 'global' + if (clouds.includes(cloud)) return + throw new TokenServiceAccountValidationError('cloud_mismatch', 400, { + step: 'microsoft_app_cloud', + reason: `${serviceLabel} does not run in the ${MICROSOFT_CLOUDS[cloud].label} cloud this credential signs in to`, + }) } -function isMicrosoftAppService(value: string | undefined): value is MicrosoftAppService { - return (MICROSOFT_APP_SERVICES as readonly (string | undefined)[]).includes(value) +/** A fixed-audience service whose tools call the global cloud. */ +function globalAudience(resource: string, serviceLabel: string): MicrosoftAppAudience { + return { + key: resource, + select: (config) => { + requireCloud(config, ['global'], serviceLabel) + return { resource } + }, + } } +const GLOBAL_GRAPH = globalAudience(MICROSOFT_CLOUDS.global.graphOrigin, 'Microsoft Graph') + /** - * Resolves an executor-authorized Microsoft app credential (the executor - * already checked the caller may use it) to a token for the service the tool - * belongs to. The resource is decided by the tool's service, never by caller - * params: Graph for Entra ID, SharePoint, and Intune, the stored environment for - * Dataverse. Any other service is refused, so a Microsoft block that needs a - * signed-in user can never receive an app-only token. + * The Power BI REST API's token audience in the global cloud. A service principal needs no Entra + * API permissions for it; the Power BI tenant setting that lets service principals use the APIs + * and workspace membership decide what it can reach. + * @see https://learn.microsoft.com/en-us/power-bi/developer/embedded/embed-service-principal */ -export async function resolveMicrosoftAppCredentialToken( +const POWERBI_RESOURCE = 'https://analysis.windows.net/powerbi/api' + +/** + * The fixed-audience Microsoft services that may use this credential, and the resource each + * one's token is for. Each service's `serviceAccountProviderId` in `oauth.ts` offers the + * credential in its picker; the resolver accepts these and the operation-minted Azure Data + * Explorer service. The resource comes from the service and the stored credential, never from + * caller params. + */ +const MICROSOFT_APP_SERVICE_AUDIENCES: Record = { + 'microsoft-ad': GLOBAL_GRAPH, + sharepoint: GLOBAL_GRAPH, + 'microsoft-intune': GLOBAL_GRAPH, + 'microsoft-powerbi': globalAudience(POWERBI_RESOURCE, 'Power BI'), + 'microsoft-dataverse': { + // The environment is part of the stored secret, so one secret version has one environment. + key: 'dataverse', + select: (config) => { + requireCloud(config, ['global'], 'Dataverse') + if (!config.environmentUrl) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'microsoft_app_environment', + reason: 'credential has no Dataverse environment; reconnect it with one', + }) + } + return { resource: config.environmentUrl, instanceUrl: config.environmentUrl } + }, + }, +} + +/** + * Minted tokens per credential, stored-secret version, and token audience: a reconnect + * re-encrypts the secret and so never reuses the old app's token, and services that share an + * audience (the Graph ones) share one token. + */ +const tokenCache = new LRUCache({ max: TOKEN_CACHE_MAX }) + +interface StoredMicrosoftAppCredential { + encryptedKey: string + /** Distinguishes stored-secret versions, so a reconnect never reuses the old app's token. */ + secretVersion: string + owner: WorkloadIdentityOwner +} + +/** + * Reads an active Microsoft app credential without decrypting it, refusing any other row. With + * `scope`, the row must also belong to that workspace or organization: an operation that resolves + * the credential after the executor authorized it re-checks it against the run's own owner. + */ +async function loadMicrosoftAppCredential( credentialId: string, - service: string | undefined, - signal?: AbortSignal -): Promise { - if (!isMicrosoftAppService(service)) { - throw new TokenServiceAccountValidationError('invalid_credentials', 400, { - step: 'microsoft_app_service', - reason: 'this Microsoft service needs a signed-in user, not an app-only credential', - }) - } + scope?: WorkloadIdentityOwner +): Promise { + const unavailable = () => + new MicrosoftAppCredentialError('Microsoft app credential is unavailable') + if (scope && !scope.workspaceId && !scope.organizationId) throw unavailable() const [row] = await db .select({ workspaceId: credential.workspaceId, @@ -713,43 +829,54 @@ export async function resolveMicrosoftAppCredentialToken( .from(credential) .where(eq(credential.id, credentialId)) .limit(1) + const ownerMatches = + !scope || + (row && + ((!!scope.workspaceId && + row.workspaceId === scope.workspaceId && + row.organizationId === null) || + (!!scope.organizationId && + row.organizationId === scope.organizationId && + row.workspaceId === null))) if ( !row || + !ownerMatches || row.type !== 'service_account' || row.providerId !== MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID || row.revokedAt !== null || !row.encryptedServiceAccountKey ) { - throw new MicrosoftAppCredentialError('Microsoft app credential is unavailable') + throw unavailable() } - const encryptedKey = row.encryptedServiceAccountKey - const cacheKey = `${credentialId}:${service === 'microsoft-dataverse' ? 'dataverse' : 'graph'}:${encryptedKey.slice(0, 32)}` + return { + encryptedKey: row.encryptedServiceAccountKey, + secretVersion: row.encryptedServiceAccountKey.slice(0, 32), + owner: row.workspaceId + ? { workspaceId: row.workspaceId } + : { organizationId: row.organizationId ?? undefined }, + } +} + +/** Mints (or reuses) the credential's token for one audience, decrypting only on a cache miss. */ +async function mintMicrosoftAppToken( + credentialId: string, + stored: StoredMicrosoftAppCredential, + audience: MicrosoftAppAudience, + signal?: AbortSignal +): Promise { + const cacheKey = `${credentialId}:${stored.secretVersion}:${audience.key}` const cached = tokenCache.get(cacheKey) if (cached) return cached signal?.throwIfAborted() // Concurrent block runs share one mint, so it is not tied to any one caller's signal. return coalesceLocally(`microsoft-app:${cacheKey}`, async () => { - const { decrypted } = await decryptSecret(encryptedKey) + const { decrypted } = await decryptSecret(stored.encryptedKey) const { config } = storedSecretSchema.parse(JSON.parse(decrypted)) - let resource = GRAPH_RESOURCE - let instanceUrl: string | undefined - if (service === 'microsoft-dataverse') { - if (!config.environmentUrl) { - throw new TokenServiceAccountValidationError('invalid_credentials', 400, { - step: 'microsoft_app_environment', - reason: 'credential has no Dataverse environment; reconnect it with one', - }) - } - resource = config.environmentUrl - instanceUrl = config.environmentUrl - } - const owner: WorkloadIdentityOwner = row.workspaceId - ? { workspaceId: row.workspaceId } - : { organizationId: row.organizationId ?? undefined } + const { resource, instanceUrl } = audience.select(config) let token: EntraToken try { - token = await requestRunEntraToken(config, resource, owner) + token = await requestRunEntraToken(config, resource, stored.owner) } catch (error) { if (!(error instanceof MicrosoftAppCredentialError)) throw error // Report in the shared service-account codes: an outage is not a bad credential, and a @@ -763,9 +890,83 @@ export async function resolveMicrosoftAppCredentialToken( } ) } - const value: CachedToken = { accessToken: token.accessToken, instanceUrl } + const value: MicrosoftAppCredentialToken = { + accessToken: token.accessToken, + ...(instanceUrl ? { instanceUrl } : {}), + } const ttl = token.expiresInSeconds * 1000 - TOKEN_REUSE_MARGIN_MS if (ttl > 0) tokenCache.set(cacheKey, value, { ttl }) return value }) } + +/** + * Resolves an executor-authorized Microsoft app credential (the executor + * already checked the caller may use it) to a token for the service the tool + * belongs to. The resource is decided by the tool's service, never by caller + * params: Graph for Entra ID, SharePoint, and Intune, the Power BI REST API for + * Power BI, the stored environment for Dataverse. Azure Data Explorer, whose + * operation chooses the cluster per call, gets the credential ID back (as AWS + * does) for {@link resolveMicrosoftAppResourceToken}. Any other service is + * refused, so a Microsoft block that needs a signed-in user can never receive an + * app-only token. + */ +export async function resolveMicrosoftAppCredentialToken( + credentialId: string, + service: string | undefined, + signal?: AbortSignal +): Promise { + const operationMinted = service === AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH.provider + const audience = + service && Object.hasOwn(MICROSOFT_APP_SERVICE_AUDIENCES, service) + ? MICROSOFT_APP_SERVICE_AUDIENCES[service] + : undefined + if (!audience && !operationMinted) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'microsoft_app_service', + reason: 'this Microsoft service needs a signed-in user, not an app-only credential', + }) + } + const stored = await loadMicrosoftAppCredential(credentialId) + if (!audience) return { accessToken: credentialId } + return mintMicrosoftAppToken(credentialId, stored, audience, signal) +} + +/** + * Resolves an executor-authorized Microsoft app credential to a token for a resource the calling + * operation chose per call and validated against its own host allowlist: an Azure Data Explorer + * cluster, whose token audience is the cluster's `https://` with no port or path. The + * credential must belong to `scope`, the run's own workspace or organization. `clouds` names the + * Microsoft clouds that resource lives in; a credential signed in to any other cloud is refused, + * because its tokens would never be accepted there. + * @see https://learn.microsoft.com/en-us/kusto/api/rest/authenticate-with-msal + */ +export async function resolveMicrosoftAppResourceToken( + credentialId: string, + scope: WorkloadIdentityOwner, + audience: { resource: string; clouds: readonly MicrosoftCloudId[]; serviceLabel: string }, + signal?: AbortSignal +): Promise { + const origin = URL.canParse(audience.resource) ? new URL(audience.resource) : undefined + if (origin?.protocol !== 'https:' || `https://${origin.hostname}` !== audience.resource) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'microsoft_app_resource', + reason: 'the token audience must be an https origin with no port or path', + }) + } + const stored = await loadMicrosoftAppCredential(credentialId, scope) + const token = await mintMicrosoftAppToken( + credentialId, + stored, + { + // A cached token stands for a passed cloud check, so the clouds are part of the key. + key: `${audience.resource}|${[...audience.clouds].sort(compareStrings).join(',')}`, + select: (config) => { + requireCloud(config, audience.clouds, `This ${audience.serviceLabel} resource`) + return { resource: audience.resource } + }, + }, + signal + ) + return token.accessToken +} diff --git a/apps/sim/lib/credentials/microsoft-clouds.ts b/apps/sim/lib/credentials/microsoft-clouds.ts new file mode 100644 index 00000000000..621031ff5a4 --- /dev/null +++ b/apps/sim/lib/credentials/microsoft-clouds.ts @@ -0,0 +1,51 @@ +/** + * The Microsoft cloud instances a saved Microsoft app credential can sign in to. Each is a + * separate Entra deployment with its own token endpoint and Microsoft Graph root, and its tokens + * are not interchangeable with another cloud's. A credential saved without a cloud is `global`, + * which every credential saved before national clouds existed is. + * + * Microsoft 365 GCC (moderate) tenants use the global endpoints; GCC High and DoD tenants use the + * US Government ones. + * + * @see https://learn.microsoft.com/en-us/graph/deployments + * @see https://learn.microsoft.com/en-us/entra/identity-platform/authentication-national-cloud + */ + +export const MICROSOFT_CLOUD_IDS = ['global', 'usgov', 'usgov_dod', 'china'] as const + +export type MicrosoftCloudId = (typeof MICROSOFT_CLOUD_IDS)[number] + +interface MicrosoftCloud { + /** Connect-dialog label. */ + label: string + /** The Entra token service root (`//oauth2/v2.0/token`). */ + loginOrigin: string + /** The Microsoft Graph service root, also the Graph token audience. */ + graphOrigin: string +} + +export const MICROSOFT_CLOUDS: Record = { + global: { + label: 'Global (including GCC)', + loginOrigin: 'https://login.microsoftonline.com', + graphOrigin: 'https://graph.microsoft.com', + }, + usgov: { + label: 'US Government (GCC High)', + loginOrigin: 'https://login.microsoftonline.us', + graphOrigin: 'https://graph.microsoft.us', + }, + usgov_dod: { + label: 'US Government (DoD)', + loginOrigin: 'https://login.microsoftonline.us', + graphOrigin: 'https://dod-graph.microsoft.us', + }, + china: { + label: 'China (operated by 21Vianet)', + // Microsoft Graph's deployment table lists login.chinacloudapi.cn; the Entra national-cloud + // table, the identity platform's own reference, lists this one, which legacy inline Azure + // Data Explorer sign-ins already use. + loginOrigin: 'https://login.partner.microsoftonline.cn', + graphOrigin: 'https://microsoftgraph.chinacloudapi.cn', + }, +} diff --git a/apps/sim/lib/credentials/oracledb-service-account.server.ts b/apps/sim/lib/credentials/oracledb-service-account.server.ts index 768ba383eb0..f48ae717ebc 100644 --- a/apps/sim/lib/credentials/oracledb-service-account.server.ts +++ b/apps/sim/lib/credentials/oracledb-service-account.server.ts @@ -1,8 +1,9 @@ -import { db } from '@sim/db' -import { credential } from '@sim/db/schema' -import { and, eq, isNull, or } from 'drizzle-orm' import { z } from 'zod' -import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + type CredentialOwnerScope, + readOwnerScopedServiceAccountSecret, +} from '@/lib/credentials/owner-scoped-secret.server' import { executeOracleStatements } from '@/lib/internal/oracledb/client' import { oracleConnectionInputSchema } from '@/lib/internal/oracledb/schema' import { ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' @@ -46,55 +47,21 @@ export async function verifyAndEncryptOracleDatabaseCredential(serialized: strin /** Loads only an executor-authorized reference bound to its trusted owner scope and provider. */ export async function getOracleDatabaseCredential( credentialId: string, - scope: { workspaceId?: string; organizationId?: string } + scope: CredentialOwnerScope ) { const unavailable = () => new Error('Oracle Database credential is unavailable') - if (!credentialId || (!scope.workspaceId && !scope.organizationId)) throw unavailable() - const [row] = await db - .select({ - workspaceId: credential.workspaceId, - organizationId: credential.organizationId, - type: credential.type, - providerId: credential.providerId, - revokedAt: credential.revokedAt, - encryptedServiceAccountKey: credential.encryptedServiceAccountKey, - }) - .from(credential) - .where( - and( - eq(credential.id, credentialId), - or( - scope.workspaceId - ? and(eq(credential.workspaceId, scope.workspaceId), isNull(credential.organizationId)) - : undefined, - scope.organizationId - ? and( - eq(credential.organizationId, scope.organizationId), - isNull(credential.workspaceId) - ) - : undefined - ) - ) + let decrypted: string | null + try { + decrypted = await readOwnerScopedServiceAccountSecret( + credentialId, + ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID, + scope ) - .limit(1) - const ownerMatches = - row && - ((row.workspaceId === scope.workspaceId && - !!scope.workspaceId && - row.organizationId === null) || - (row.organizationId === scope.organizationId && - !!scope.organizationId && - row.workspaceId === null)) - if ( - !ownerMatches || - row.type !== 'service_account' || - row.providerId !== ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID || - row.revokedAt !== null || - !row.encryptedServiceAccountKey - ) + } catch { throw unavailable() + } + if (decrypted === null) throw unavailable() try { - const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) return secretSchema.parse(JSON.parse(decrypted)).connection } catch { throw unavailable() diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 3efb824c52d..8ea4fc7ac18 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -286,6 +286,7 @@ export async function createCredentialRecord( let resolvedEnvOwnerUserId: string | null = null let resolvedEncryptedServiceAccountKey: string | null = null let resolvedWorkloadIdentitySubject: string | undefined + let resolvedTokenExpiresAt: Date | null = null const extraAuditMetadata: Record = {} if (type === 'oauth') { @@ -353,6 +354,7 @@ export async function createCredentialRecord( if (!resolvedDisplayName) resolvedDisplayName = secret.displayName resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey resolvedWorkloadIdentitySubject = secret.newWorkloadIdentitySubject + resolvedTokenExpiresAt = secret.tokenExpiresAt ?? null Object.assign(extraAuditMetadata, secret.auditMetadata) } catch (error) { if (error instanceof KeylessCredentialRefusedError) return keylessFailure(error) @@ -559,6 +561,7 @@ export async function createCredentialRecord( envKey: resolvedEnvKey, envOwnerUserId: resolvedEnvOwnerUserId, encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey, + accessTokenExpiresAt: resolvedTokenExpiresAt, createdBy: userId, createdAt: now, updatedAt: now, diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 6f2f60cdbe0..0178be1a078 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -399,6 +399,41 @@ describe('performUpdateCredential — service-account secret rotation', () => { ) }) + it.each([ + { domain: undefined, expected: 'eu' }, + { domain: ' ', expected: 'eu' }, + { domain: 'federal', expected: 'federal' }, + ])( + 'sends region $expected when a token credential stored on eu is reconnected with $domain', + async ({ domain, expected }) => { + mockCredential({ providerId: 'launchdarkly-service-account', displayName: 'Flags (eu)' }) + mockStoredBlob({ + type: 'token_service_account', + providerId: 'launchdarkly-service-account', + apiToken: 'old-token', + domain: 'eu', + }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'launchdarkly-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: `LaunchDarkly (${expected})`, + auditMetadata: {}, + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + apiToken: 'new-token', + ...(domain === undefined ? {} : { domain }), + }) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'launchdarkly-service-account', + expect.objectContaining({ apiToken: 'new-token', domain: expected }) + ) + } + ) + it('fails the reconnect when the stored secret cannot be decrypted', async () => { mockCredential() queueTableRows(schemaMock.credential, [{ key: 'stored-cipher' }]) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 5674a09afd1..5f1dada0b04 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -49,7 +49,10 @@ import { ServiceAccountSecretError, verifyAndBuildServiceAccountSecret, } from '@/lib/credentials/service-account-secret' -import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors' +import { + getTokenServiceAccountDomainDefault, + SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/credentials/token-service-accounts/descriptors' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { assertReconnectedSubjectUnclaimed, @@ -159,7 +162,7 @@ async function readStoredSecretBlob(credentialId: string): Promise | null, - field: 'dataCenter' | 'scope' | 'authMethod' | 'username' | 'atlassianProduct' + field: 'dataCenter' | 'scope' | 'authMethod' | 'username' | 'atlassianProduct' | 'domain' ): string | undefined { const value = blob?.[field] return typeof value === 'string' && value ? value : undefined @@ -364,6 +367,10 @@ export async function updateCredentialRecord( providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && params.atlassianProduct === undefined const needsStoredUsername = params.username === undefined && isMultiGrantProvider + // A token provider's defaulted region or host is left blank on a reconnect that keeps it; + // filling the default instead would move an EU or sandbox credential to the default host. + const needsStoredDomain = + !params.domain?.trim() && getTokenServiceAccountDomainDefault(providerId) !== undefined // A keyless AWS role, Databricks principal, Microsoft app, Google service account, or // Snowflake service user keeps its trusted subject across reconnects. const needsStoredBoundSubject = @@ -389,6 +396,7 @@ export async function updateCredentialRecord( needsStoredScope || needsStoredAuthMethod || needsStoredUsername || + needsStoredDomain || needsStoredIdentity || needsStoredAtlassianProduct || needsStoredBoundSubject @@ -445,7 +453,7 @@ export async function updateCredentialRecord( signingSecret: params.signingSecret, botToken: params.botToken, apiToken: params.apiToken, - domain: params.domain, + domain: needsStoredDomain ? readStoredField(storedBlob, 'domain') : params.domain, providerWorkspaceId: params.providerWorkspaceId, atlassianProduct: needsStoredAtlassianProduct ? readStoredField(storedBlob, 'atlassianProduct') === 'confluence' @@ -480,6 +488,8 @@ export async function updateCredentialRecord( }, }) updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey + // The replaced token's expiry never outlives it: a token that states none clears it. + updates.accessTokenExpiresAt = secret.tokenExpiresAt ?? null rotatedSlackBotUserId = secret.botUserId rotatedAuditMetadata = secret.auditMetadata if (needsStoredBoundSubject) rotatedKeylessSecret = secret.encryptedServiceAccountKey diff --git a/apps/sim/lib/credentials/owner-scoped-secret.server.ts b/apps/sim/lib/credentials/owner-scoped-secret.server.ts new file mode 100644 index 00000000000..7d5e8055cc7 --- /dev/null +++ b/apps/sim/lib/credentials/owner-scoped-secret.server.ts @@ -0,0 +1,72 @@ +import { db } from '@sim/db' +import { credential } from '@sim/db/schema' +import { and, eq, isNull, or } from 'drizzle-orm' +import { decryptSecret } from '@/lib/core/security/encryption' + +/** The workspace or organization an executor-authorized credential must belong to. */ +export interface CredentialOwnerScope { + workspaceId?: string + organizationId?: string +} + +/** + * Decrypts a live service-account credential of `providerId` owned by exactly + * `scope`: a workspace credential when the workspace matches, or an + * organization credential when the organization matches. Returns `null` for + * anything else (no scope, another owner, another provider, revoked, or no + * stored secret) without decrypting, so callers fail closed with their own + * provider-specific message. `getAwsCredentialConfig` keeps its own copy of + * this read because it also needs the matched owner. + */ +export async function readOwnerScopedServiceAccountSecret( + credentialId: string, + providerId: string, + scope: CredentialOwnerScope +): Promise { + if (!credentialId || (!scope.workspaceId && !scope.organizationId)) return null + const [row] = await db + .select({ + workspaceId: credential.workspaceId, + organizationId: credential.organizationId, + type: credential.type, + providerId: credential.providerId, + revokedAt: credential.revokedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where( + and( + eq(credential.id, credentialId), + or( + scope.workspaceId + ? and(eq(credential.workspaceId, scope.workspaceId), isNull(credential.organizationId)) + : undefined, + scope.organizationId + ? and( + eq(credential.organizationId, scope.organizationId), + isNull(credential.workspaceId) + ) + : undefined + ) + ) + ) + .limit(1) + const ownerMatches = + row && + ((row.workspaceId === scope.workspaceId && + !!scope.workspaceId && + row.organizationId === null) || + (row.organizationId === scope.organizationId && + !!scope.organizationId && + row.workspaceId === null)) + if ( + !ownerMatches || + row.type !== 'service_account' || + row.providerId !== providerId || + row.revokedAt !== null || + !row.encryptedServiceAccountKey + ) + return null + const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) + return decrypted +} diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 819fb20a27d..38566040553 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -44,6 +44,8 @@ export interface VisibleWorkspaceCredential { envKey: string | null envOwnerUserId: string | null providerTenantId?: string | null + /** When the saved token stops working, for a service account or personal token that states it. */ + accessTokenExpiresAt?: Date | null createdBy: string createdAt: Date updatedAt: Date @@ -197,6 +199,7 @@ export async function listVisibleWorkspaceCredentials(params: { envKey: credential.envKey, envOwnerUserId: credential.envOwnerUserId, providerTenantId: credential.providerTenantId, + accessTokenExpiresAt: credential.accessTokenExpiresAt, createdBy: credential.createdBy, createdAt: credential.createdAt, updatedAt: credential.updatedAt, diff --git a/apps/sim/lib/credentials/saved-token-expiry.ts b/apps/sim/lib/credentials/saved-token-expiry.ts new file mode 100644 index 00000000000..c36a7b9aa16 --- /dev/null +++ b/apps/sim/lib/credentials/saved-token-expiry.ts @@ -0,0 +1,65 @@ +import { db } from '@sim/db' +import { credential } from '@sim/db/schema' +import { and, eq, lte } from 'drizzle-orm' +import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors' + +/** The provider's own failure that a saved token's expiry explains. */ +interface ProviderRefusal { + status: number + statusText?: unknown + data?: unknown +} + +/** + * A provider refused a saved token that is past the expiry its vendor stated when it was saved. + * Its message tells the user to replace the token, while the failure stays the provider's refusal: + * it keeps the provider's `status`, `statusText`, and `data`, so callers that branch on the HTTP + * failure still see it, and the boundary that replaces the refusal with it carries the refusal's + * log attribution over. + */ +class SavedTokenExpiredError extends Error { + readonly status: number + readonly statusText?: string + readonly data?: unknown + + constructor(message: string, refusal: ProviderRefusal) { + super(message) + this.name = 'SavedTokenExpiredError' + this.status = refusal.status + if (typeof refusal.statusText === 'string') this.statusText = refusal.statusText + if (refusal.data !== undefined) this.data = refusal.data + } +} + +/** + * Explains a provider's refusal of a saved service-account token when the credential records a + * vendor-stated expiry that has passed: the run then says the token expired and must be replaced, + * instead of reporting a bare authorization failure. Returns `null`, leaving the provider's own + * failure to stand, when the credential records no expiry, the expiry is still ahead, or the + * credential is not a saved service account. Never refuses a token on its own: only a refusal the + * provider already made is explained. + */ +export async function explainExpiredSavedToken( + credentialId: string, + refusal: ProviderRefusal +): Promise { + const [row] = await db + .select({ providerId: credential.providerId, expiresAt: credential.accessTokenExpiresAt }) + .from(credential) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'service_account'), + lte(credential.accessTokenExpiresAt, new Date()) + ) + ) + .limit(1) + if (!row?.expiresAt) return null + const descriptor = getTokenServiceAccountDescriptor(row.providerId) + const token = descriptor ? `${descriptor.serviceLabel} ${descriptor.tokenNoun}` : 'token' + const replacement = descriptor?.tokenNoun ?? 'token' + return new SavedTokenExpiredError( + `The saved ${token} expired on ${row.expiresAt.toISOString().slice(0, 10)} UTC. Create a new ${replacement} and reconnect the credential.`, + refusal + ) +} diff --git a/apps/sim/lib/credentials/service-account-fields.test.ts b/apps/sim/lib/credentials/service-account-fields.test.ts index b463f1fbffa..9b94f4d1f7b 100644 --- a/apps/sim/lib/credentials/service-account-fields.test.ts +++ b/apps/sim/lib/credentials/service-account-fields.test.ts @@ -14,6 +14,21 @@ describe('required fields for a service-account create', () => { ) }) + it.each([ + 'launchdarkly-service-account', + 'langsmith-service-account', + 'square-service-account', + 'sendgrid-service-account', + 'hex-service-account', + 'sentry-service-account', + ])( + 'lets a %s create omit the region or host it defaults, as it could before one existed', + (providerId) => { + expect(getMissingServiceAccountFields(providerId, { apiToken: 'token' })).toEqual([]) + expect(getMissingServiceAccountFields(providerId, {})).toEqual(['apiToken']) + } + ) + it('does not let a JSON configuration stand in for another token provider’s token', () => { expect( getMissingServiceAccountFields('hubspot-service-account', { serviceAccountJson: '{}' }) @@ -73,4 +88,43 @@ describe('required fields for a service-account create', () => { ).toEqual(['clientId', 'clientSecret']) expect(getMissingServiceAccountFields('linear-service-account', {})).toEqual(['apiToken']) }) + + it('asks a ServiceNow OAuth client create for the instance but not a username', () => { + expect( + getMissingServiceAccountFields('servicenow-service-account', { + authMethod: 'client_credentials', + }) + ).toEqual(['clientId', 'clientSecret', 'domain']) + expect(getMissingServiceAccountFields('servicenow-service-account', {})).toEqual([ + 'apiToken', + 'providerWorkspaceId', + 'domain', + ]) + }) + + it('asks an Infisical Universal Auth create for the client ID, secret, and instance', () => { + expect( + getMissingServiceAccountFields('infisical-service-account', { + authMethod: 'client_credentials', + }) + ).toEqual(['clientId', 'clientSecret', 'domain']) + }) +}) + +describe('an optional token-provider field', () => { + it('does not ask a Railway create for the workspace ID only workspace tokens need', () => { + expect( + getMissingServiceAccountFields('railway-service-account', { + apiToken: 'token', + domain: 'project', + }) + ).toEqual([]) + }) + + it('still asks a Railway create for its token and token type', () => { + expect(getMissingServiceAccountFields('railway-service-account', {})).toEqual([ + 'apiToken', + 'domain', + ]) + }) }) diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 60113ef6bb7..b9560be9bb5 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -1,4 +1,5 @@ import { CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/client-credential-accounts/descriptors' +import { DATABASE_CONNECTION_DESCRIPTORS } from '@/lib/credentials/database-connections/descriptors' import { CLIENT_CREDENTIALS_AUTH_METHOD, TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS, @@ -55,6 +56,12 @@ export type ServiceAccountFieldId = */ export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = { [ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'], + ...Object.fromEntries( + Object.keys(DATABASE_CONNECTION_DESCRIPTORS).map((providerId) => [ + providerId, + ['serviceAccountJson'] as const, + ]) + ), [AWS_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'], [DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'], [MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'], diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts index 918b2525551..facdb6873a9 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -2,6 +2,7 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, } from '@/lib/credentials/client-credential-accounts/descriptors' +import { isDatabaseConnectionProviderId } from '@/lib/credentials/database-connections/descriptors' import { getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, @@ -39,6 +40,7 @@ export function asServiceAccountProviderId( value === AWS_SERVICE_ACCOUNT_PROVIDER_ID || value === DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID || value === MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID || + isDatabaseConnectionProviderId(value) || isTokenServiceAccountProviderId(value) || isClientCredentialAccountProviderId(value) ) { @@ -81,7 +83,11 @@ export function getServiceAccountGatingBlockType(providerId: string): string | n export function getServiceAccountConnectNoun(providerId: string): string { if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) return 'custom bot' if (providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) return 'API key' - if (providerId === ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID) return 'database connection' + if ( + providerId === ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID || + isDatabaseConnectionProviderId(providerId) + ) + return 'database connection' if (providerId === AWS_SERVICE_ACCOUNT_PROVIDER_ID) return 'AWS credential' if (providerId === DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID) return 'Databricks connection' if (providerId === MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID) return 'Microsoft app credential' diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index cedf046e89d..3bf33e761d1 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -11,12 +11,14 @@ const { mockNormalizeDomain, mockClientCredentialMinter, mockVerifyAndEncryptOci, + mockPostgresqlQuery, } = vi.hoisted(() => ({ mockFetchSlackTeamId: vi.fn(), mockValidateAtlassian: vi.fn(), mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()), mockClientCredentialMinter: vi.fn(), mockVerifyAndEncryptOci: vi.fn(), + mockPostgresqlQuery: vi.fn(), })) vi.mock('@/lib/core/security/encryption', () => encryptionMock) @@ -59,6 +61,9 @@ vi.mock('@/lib/credentials/oci-api-key-service-account.server', () => ({ }, verifyAndEncryptOciApiKeyCredential: mockVerifyAndEncryptOci, })) +vi.mock('@/lib/internal/postgresql/operations', () => ({ + executePostgresqlQuery: mockPostgresqlQuery, +})) vi.mock('@/lib/api/contracts/credentials', () => ({ serviceAccountJsonSchema: { safeParse: (value: string) => { @@ -93,6 +98,7 @@ import { } from '@/lib/credentials/service-account-secret' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -831,3 +837,469 @@ describe('a ServiceNow login', () => { expect(secureFetch).not.toHaveBeenCalled() }) }) + +describe('a Splunk username and password', () => { + const secureFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP + + beforeEach(() => { + mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.7', + originalHostname: 'splunk.example.com', + }) + secureFetch.mockReset().mockResolvedValue( + new Response(JSON.stringify({ entry: [{ content: { username: 'svc-sim' } }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + }) + + it('verifies and stores a password with surrounding spaces exactly as typed', async () => { + const result = await verifyAndBuildServiceAccountSecret('splunk-service-account', { + apiToken: ' pass word ', + providerWorkspaceId: 'svc-sim', + domain: 'splunk.example.com:8089', + }) + + expect(secureFetch).toHaveBeenCalledWith( + expect.any(String), + '203.0.113.7', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from('svc-sim: pass word ').toString('base64')}`, + }), + }) + ) + expect(JSON.parse(result.encryptedServiceAccountKey).apiToken).toBe(' pass word ') + }) + + it('still sends a pasted token without its surrounding whitespace', async () => { + await verifyAndBuildServiceAccountSecret('splunk-service-account', { + apiToken: ' splunk-token\n', + domain: 'splunk.example.com:8089', + }) + + expect(secureFetch).toHaveBeenCalledWith( + expect.any(String), + '203.0.113.7', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer splunk-token' }), + }) + ) + }) +}) + +describe('a token provider whose region or host defaults to the one it always used', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) + fetchMock.mockReset().mockResolvedValue(Response.json({ accountId: 'acct-1' })) + vi.stubGlobal('fetch', fetchMock) + }) + + const requestedUrl = () => new Request(fetchMock.mock.calls[0][0], fetchMock.mock.calls[0][1]).url + + it.each([ + [ + 'launchdarkly-service-account', + undefined, + 'https://app.launchdarkly.com/api/v2/caller-identity', + 'us', + ], + [ + 'launchdarkly-service-account', + 'eu', + 'https://app.eu.launchdarkly.com/api/v2/caller-identity', + 'eu', + ], + [ + 'launchdarkly-service-account', + 'Federal', + 'https://app.launchdarkly.us/api/v2/caller-identity', + 'federal', + ], + [ + 'langsmith-service-account', + undefined, + 'https://api.smith.langchain.com/api/v1/workspaces', + 'us', + ], + [ + 'langsmith-service-account', + 'eu', + 'https://eu.api.smith.langchain.com/api/v1/workspaces', + 'eu', + ], + [ + 'langsmith-service-account', + 'azure-us', + 'https://azure.api.smith.langchain.com/api/v1/workspaces', + 'azure-us', + ], + [ + 'square-service-account', + undefined, + 'https://connect.squareup.com/v2/locations', + 'production', + ], + [ + 'square-service-account', + 'sandbox', + 'https://connect.squareupsandbox.com/v2/locations', + 'sandbox', + ], + ['sendgrid-service-account', undefined, 'https://api.sendgrid.com/v3/scopes', 'global'], + ['sendgrid-service-account', 'eu', 'https://api.eu.sendgrid.com/v3/scopes', 'eu'], + [ + 'hex-service-account', + undefined, + 'https://app.hex.tech/api/v1/projects?limit=1', + 'app.hex.tech', + ], + [ + 'hex-service-account', + 'eu.hex.tech', + 'https://eu.hex.tech/api/v1/projects?limit=1', + 'eu.hex.tech', + ], + [ + 'hex-service-account', + 'https://Acme.hex.tech/', + 'https://acme.hex.tech/api/v1/projects?limit=1', + 'acme.hex.tech', + ], + ['sentry-service-account', undefined, 'https://sentry.io/api/0/organizations/', 'us'], + ['sentry-service-account', 'de', 'https://de.sentry.io/api/0/organizations/', 'de'], + ])('%s with %s is checked at %s and stored as %s', async (providerId, domain, url, stored) => { + const result = await verifyAndBuildServiceAccountSecret(providerId, { + apiToken: 'token-1', + ...(domain === undefined ? {} : { domain }), + }) + + expect(requestedUrl()).toBe(url) + expect(JSON.parse(result.encryptedServiceAccountKey)).toMatchObject({ + apiToken: 'token-1', + domain: stored, + }) + }) + + it.each([ + [undefined, 'https://amplitude.com/api/2/events/list', 'us'], + ['eu', 'https://analytics.eu.amplitude.com/api/2/events/list', 'eu'], + ])( + 'checks an Amplitude project with residency %s at %s and stores it as %s', + async (domain, url, stored) => { + const result = await verifyAndBuildServiceAccountSecret('amplitude-service-account', { + apiToken: 'secret-1', + providerWorkspaceId: 'api-key-1', + ...(domain === undefined ? {} : { domain }), + }) + + expect(requestedUrl()).toBe(url) + expect(JSON.parse(result.encryptedServiceAccountKey)).toMatchObject({ domain: stored }) + } + ) + + it.each([ + ['launchdarkly-service-account', 'LaunchDarkly'], + ['langsmith-service-account', 'LangSmith'], + ['square-service-account', 'Square'], + ['sendgrid-service-account', 'SendGrid'], + ['hex-service-account', 'Hex'], + ['sentry-service-account', 'Sentry'], + ])( + 'names a %s credential on its default host %s, as before the field existed', + async (providerId, label) => { + const result = await verifyAndBuildServiceAccountSecret(providerId, { apiToken: 'token-1' }) + + expect(result.displayName).toBe(label) + } + ) + + it.each([ + ['sentry-service-account', 'de', 'Sentry (de)'], + ['hex-service-account', 'eu.hex.tech', 'Hex (eu.hex.tech)'], + ])('names a %s credential in %s after it', async (providerId, domain, displayName) => { + const result = await verifyAndBuildServiceAccountSecret(providerId, { + apiToken: 'token-1', + domain, + }) + + expect(result.displayName).toBe(displayName) + }) + + const hostNotFound = () => + Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } }) + + it.each([ + ['sendgrid-service-account', undefined], + ['sendgrid-service-account', 'eu'], + ['sentry-service-account', 'de'], + ['launchdarkly-service-account', 'federal'], + ['hex-service-account', undefined], + ])( + 'reports a %s vendor host (%s) that does not resolve as the vendor being unavailable', + async (providerId, domain) => { + fetchMock.mockRejectedValue(hostNotFound()) + + await expect( + verifyAndBuildServiceAccountSecret(providerId, { + apiToken: 'token-1', + ...(domain === undefined ? {} : { domain }), + }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + } + ) + + it('reports a typed Hex host that does not resolve as a host not found', async () => { + fetchMock.mockRejectedValue(hostNotFound()) + + await expect( + verifyAndBuildServiceAccountSecret('hex-service-account', { + apiToken: 'token-1', + domain: 'acme.hex.tech', + }) + ).rejects.toMatchObject({ code: 'site_not_found' }) + }) + + it.each([ + ['launchdarkly-service-account', 'app.launchdarkly.example.com'], + ['langsmith-service-account', 'eu.api.smith.langchain.com.example.com'], + ['square-service-account', 'connect.squareupsandbox.com'], + ['sendgrid-service-account', 'api.eu.sendgrid.com'], + ['hex-service-account', 'hex.tech.example.com'], + ['hex-service-account', 'acme.hex.tech@example.com'], + ['hex-service-account', 'a.b.hex.tech'], + ['hex-service-account', 'status.hex.tech'], + ['hex-service-account', 'learn.hex.tech'], + ['sentry-service-account', 'de.sentry.io.example.com'], + ])( + 'refuses %s with an unlisted region or host %s without sending the token', + async (providerId, domain) => { + await expect( + verifyAndBuildServiceAccountSecret(providerId, { apiToken: 'token-1', domain }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetchMock).not.toHaveBeenCalled() + } + ) +}) + +describe('a Railway token', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + it('saves a project token without the workspace ID only workspace tokens need', async () => { + fetchMock.mockResolvedValue( + Response.json({ data: { projectToken: { projectId: 'p1', environmentId: 'e1' } } }) + ) + + const result = await verifyAndBuildServiceAccountSecret('railway-service-account', { + apiToken: 'project-token', + domain: 'project', + }) + + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toMatchObject({ apiToken: 'project-token', domain: 'project' }) + expect(blob).not.toHaveProperty('providerWorkspaceId') + }) + + it('stores the workspace ID a workspace token was verified against', async () => { + fetchMock.mockResolvedValue( + Response.json({ data: { workspace: { id: 'ws-1', name: 'Acme' } } }) + ) + + const result = await verifyAndBuildServiceAccountSecret('railway-service-account', { + apiToken: 'workspace-token', + domain: 'workspace', + providerWorkspaceId: ' ws-1 ', + }) + + expect(JSON.parse(result.encryptedServiceAccountKey)).toMatchObject({ + domain: 'workspace', + providerWorkspaceId: 'ws-1', + }) + }) +}) + +describe('an Infisical machine identity with Universal Auth', () => { + const login = inputValidationMockFns.mockSecureFetchWithValidation + const identityCheck = inputValidationMockFns.mockSecureFetchWithPinnedIP + + beforeEach(() => { + storedCoordinatedTokens.clear() + mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.9', + originalHostname: 'secrets.example.com', + }) + login + .mockReset() + .mockResolvedValue( + Response.json({ accessToken: 'machine-token', expiresIn: 7200, tokenType: 'Bearer' }) + ) + identityCheck + .mockReset() + .mockResolvedValue( + Response.json({ identityDetails: { organization: { id: 'org-1', name: 'Acme' } } }) + ) + }) + + it('stores the client ID and secret, never a token, after proving the minted token', async () => { + const result = await verifyAndBuildServiceAccountSecret('infisical-service-account', { + authMethod: 'client_credentials', + clientId: 'identity-client', + clientSecret: 'identity-secret', + domain: 'https://Secrets.Example.com/', + }) + + expect(login.mock.calls[0]?.[0]).toBe( + 'https://secrets.example.com/api/v1/auth/universal-auth/login' + ) + expect(identityCheck).toHaveBeenCalledWith( + 'https://secrets.example.com/api/v1/identities/details', + '203.0.113.9', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer machine-token' }), + }) + ) + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toMatchObject({ + type: 'token_service_account', + providerId: 'infisical-service-account', + authMethod: 'client_credentials', + clientId: 'identity-client', + clientSecret: 'identity-secret', + domain: 'https://secrets.example.com', + }) + expect(blob.apiToken).toBeUndefined() + expect(result.encryptedServiceAccountKey).not.toContain('machine-token') + }) + + it('keeps a pasted access token on the token path when no auth method is sent', async () => { + const result = await verifyAndBuildServiceAccountSecret('infisical-service-account', { + apiToken: 'pasted-token', + domain: 'https://secrets.example.com', + }) + + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob.apiToken).toBe('pasted-token') + expect(blob.authMethod).toBeUndefined() + expect(login).not.toHaveBeenCalled() + }) + + it('requires the instance URL without logging in', async () => { + await expect( + verifyAndBuildServiceAccountSecret('infisical-service-account', { + authMethod: 'client_credentials', + clientId: 'identity-client', + clientSecret: 'identity-secret', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + expect(login).not.toHaveBeenCalled() + }) +}) + +describe('a ServiceNow OAuth client', () => { + const tokenRequest = inputValidationMockFns.mockSecureFetchWithValidation + const tableRead = inputValidationMockFns.mockSecureFetchWithPinnedIP + + beforeEach(() => { + storedCoordinatedTokens.clear() + mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.7', + originalHostname: 'acme.service-now.com', + }) + tokenRequest + .mockReset() + .mockResolvedValue(Response.json({ access_token: 'sn-minted', expires_in: 1799 })) + tableRead.mockReset().mockResolvedValue(Response.json({ result: [{ sys_id: 'abc' }] })) + }) + + it('proves the minted token with a bearer table read and stores no username', async () => { + const result = await verifyAndBuildServiceAccountSecret('servicenow-service-account', { + authMethod: 'client_credentials', + clientId: 'sn-client', + clientSecret: 'sn-secret', + domain: 'acme.service-now.com', + }) + + expect(tokenRequest.mock.calls[0]?.[0]).toBe('https://acme.service-now.com/oauth_token.do') + expect(tableRead).toHaveBeenCalledWith( + 'https://acme.service-now.com/api/now/table/sys_user?sysparm_limit=1&sysparm_fields=sys_id', + '203.0.113.7', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer sn-minted' }), + }) + ) + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toMatchObject({ + providerId: 'servicenow-service-account', + authMethod: 'client_credentials', + clientId: 'sn-client', + clientSecret: 'sn-secret', + domain: 'https://acme.service-now.com', + }) + expect(blob.providerWorkspaceId).toBeUndefined() + expect(blob.apiToken).toBeUndefined() + expect(result.encryptedServiceAccountKey).not.toContain('sn-minted') + }) + + it('reports a rejected client as invalid credentials', async () => { + tokenRequest.mockResolvedValue( + Response.json({ error: 'access_denied', error_description: 'denied' }, { status: 401 }) + ) + + await expect( + verifyAndBuildServiceAccountSecret('servicenow-service-account', { + authMethod: 'client_credentials', + clientId: 'sn-client', + clientSecret: 'sn-secret', + domain: 'acme.service-now.com', + }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(tableRead).not.toHaveBeenCalled() + }) +}) + +describe('a saved database connection', () => { + const serviceAccountJson = JSON.stringify({ + host: 'db.example.com', + port: 5432, + database: 'app', + username: 'app_user', + password: 'secret', + }) + + it('asks the user to fix the connection when the database does not answer', async () => { + mockPostgresqlQuery.mockRejectedValue(new Error('ECONNREFUSED')) + + await expect( + verifyAndBuildServiceAccountSecret(POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, { + serviceAccountJson, + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + }) + + it('keeps an encryption failure on a verified connection a server error', async () => { + mockPostgresqlQuery.mockResolvedValue({ rows: [{ sim_connection_ok: 1 }] }) + mockEncryptSecret.mockRejectedValue(new Error('KMS unavailable')) + + const saving = verifyAndBuildServiceAccountSecret(POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, { + serviceAccountJson, + }) + + await expect(saving).rejects.toThrow('KMS unavailable') + await expect(saving).rejects.not.toBeInstanceOf(ServiceAccountSecretError) + }) +}) diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 0bb97d88280..d6e1e6ae4f8 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -26,6 +26,14 @@ import { getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' import { invalidateCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' +import { + DATABASE_CONNECTION_DESCRIPTORS, + type DatabaseConnectionProviderId, +} from '@/lib/credentials/database-connections/descriptors' +import { + DatabaseConnectionVerificationError, + verifyAndEncryptDatabaseConnection, +} from '@/lib/credentials/database-connections/server' import { DatabricksCredentialError, verifyAndEncryptDatabricksCredential, @@ -49,12 +57,13 @@ import { import type { AtlassianProduct } from '@/lib/credentials/service-account-fields' import { SnowflakeCredentialError, - verifyAndEncryptSnowflakeKeylessCredential, + verifyAndEncryptSnowflakeConnection, } from '@/lib/credentials/snowflake-service-account.server' import { getTokenClientCredentialMinter } from '@/lib/credentials/token-service-accounts/client-credentials' import { CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, + isTokenServiceAccountFieldRequired, isTokenServiceAccountProviderId, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, @@ -131,6 +140,11 @@ export interface ServiceAccountSecretResult { principal: ServiceAccountPrincipal | null /** Slack custom bot: the derived bot user id (for reaction self-drop). */ botUserId?: string + /** + * When the vendor stops accepting the saved token, for a pasted token whose vendor states it at + * verification. Absent when the token never expires or the vendor does not say. + */ + tokenExpiresAt?: Date /** * Keyless AWS, Databricks, Snowflake, Microsoft app, or Google: a subject this save binds that * the credential did not already hold, which must not belong to another credential of the owner. @@ -374,7 +388,10 @@ async function buildGoogleServiceAccountSecret( } } -/** A keyless Google Cloud credential: Workload Identity Federation, no stored key. */ +/** + * A keyless Google credential: Workload Identity Federation, no stored key, + * optionally with domain-wide delegation to one Workspace user. + */ async function buildGoogleWorkloadIdentitySecret( serviceAccountJson: string, owner: ServiceAccountSecretFields['owner'] @@ -400,6 +417,12 @@ async function buildGoogleWorkloadIdentitySecret( auditMetadata: { googleClientEmail: result.serviceAccountEmail, googleWorkloadIdentityProvider: result.workloadIdentityProvider, + ...(result.delegation + ? { + googleDelegatedUser: result.delegation.user, + googleDelegatedServices: result.delegation.services.join(','), + } + : {}), ...serviceAccountPrincipalMetadata(principal), }, principal, @@ -524,6 +547,37 @@ async function buildOracleDatabaseServiceAccountSecret( } } +/** + * Builds a saved PostgreSQL, MySQL, or SQL Server connection: the submitted + * connection JSON is proven with `SELECT 1` and stored encrypted. + */ +function databaseConnectionSecretBuilder( + providerId: DatabaseConnectionProviderId +): ServiceAccountSecretBuilder { + const { label } = DATABASE_CONNECTION_DESCRIPTORS[providerId] + return async (fields) => { + if (!fields.serviceAccountJson) + throw new ServiceAccountSecretError(`${label} connection configuration is required`) + let result + try { + result = await verifyAndEncryptDatabaseConnection(providerId, fields.serviceAccountJson) + } catch (error) { + if (!(error instanceof DatabaseConnectionVerificationError)) throw error + throw new ServiceAccountSecretError( + `Could not verify the ${label} connection. Check the connection details, network access, and database permissions.` + ) + } + const principal: ServiceAccountPrincipal = { kind: 'user', id: result.username } + return { + providerId, + encryptedServiceAccountKey: result.encryptedServiceAccountKey, + displayName: result.displayName, + auditMetadata: serviceAccountPrincipalMetadata(principal), + principal, + } + } +} + async function buildOciApiKeyServiceAccountSecret( fields: ServiceAccountSecretFields ): Promise { @@ -578,30 +632,36 @@ async function buildTokenServiceAccountSecret( const apiToken = descriptor.fields.find((field) => field.id === 'apiToken')?.preserveWhitespace ? fields.apiToken : fields.apiToken?.trim() - const domain = fields.domain?.trim() - const requiresDomain = descriptor.fields.some((field) => field.id === 'domain') + const domainField = descriptor.fields.find((field) => field.id === 'domain') + const domain = fields.domain?.trim() || domainField?.defaultValue + const requiresDomain = Boolean(domainField && !domainField.optional) const providerWorkspaceId = fields.providerWorkspaceId?.trim() - const requiresWorkspace = descriptor.fields.some((field) => field.id === 'providerWorkspaceId') + const workspaceField = descriptor.fields.find((field) => field.id === 'providerWorkspaceId') + const requiresWorkspace = Boolean( + workspaceField && isTokenServiceAccountFieldRequired(workspaceField) + ) if ( !apiToken || !apiToken.trim() || (requiresDomain && !domain) || (requiresWorkspace && !providerWorkspaceId) ) { - const required = descriptor.fields.map((field) => field.id).join(' and ') + const requiredFields = descriptor.fields.filter(isTokenServiceAccountFieldRequired) throw new ServiceAccountSecretError( - `${required} ${descriptor.fields.length > 1 ? 'are' : 'is'} required for ${descriptor.serviceLabel} service account credentials` + `${requiredFields.map((field) => field.id).join(' and ')} ${requiredFields.length > 1 ? 'are' : 'is'} required for ${descriptor.serviceLabel} service account credentials` ) } const validation = await validator({ apiToken, domain, providerWorkspaceId }) + const storedDomain = validation.normalizedDomain ?? domain + const storedWorkspaceId = validation.normalizedWorkspaceId ?? providerWorkspaceId const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) const blob: TokenServiceAccountSecretBlob = { type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, providerId, apiToken, - ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}), - ...(requiresWorkspace - ? { providerWorkspaceId: validation.normalizedWorkspaceId ?? providerWorkspaceId } + ...(domainField && (requiresDomain || storedDomain) ? { domain: storedDomain } : {}), + ...(workspaceField && (requiresWorkspace || storedWorkspaceId) + ? { providerWorkspaceId: storedWorkspaceId } : {}), metadata: { ...validation.storedMetadata, ...principalMetadata }, } @@ -612,6 +672,7 @@ async function buildTokenServiceAccountSecret( displayName: validation.displayName, auditMetadata: { ...validation.auditMetadata, ...principalMetadata }, principal: validation.principal, + ...(validation.expiresAt ? { tokenExpiresAt: validation.expiresAt } : {}), } } @@ -634,8 +695,9 @@ async function buildTokenClientCredentialSecret( } const clientId = fields.clientId?.trim() const clientSecret = fields.clientSecret?.trim() - const domain = fields.domain?.trim() - const requiresDomain = descriptor.fields.some((field) => field.id === 'domain') + const domainField = descriptor.fields.find((field) => field.id === 'domain') + const domain = fields.domain?.trim() || domainField?.defaultValue + const requiresDomain = Boolean(domainField) if (!clientId || !clientSecret || (requiresDomain && !domain)) { throw new ServiceAccountSecretError( `clientId, clientSecret${requiresDomain ? ', and domain are' : ' are'} required for ${descriptor.serviceLabel} client credentials` @@ -672,9 +734,10 @@ async function buildTokenClientCredentialSecret( /** * Builds a Snowflake secret. A JSON configuration is the keyless mode (no - * stored secret); otherwise the pasted programmatic access token goes through - * the token-paste builder exactly as before. Snowflake's own refusals keep the - * shared validation codes so callers report them like any token check. + * stored secret) or the key-pair mode, as its `authType` says; otherwise the + * pasted programmatic access token goes through the token-paste builder + * exactly as before. Snowflake's own refusals keep the shared validation codes + * so callers report them like any token check. */ async function buildSnowflakeServiceAccountSecret( fields: ServiceAccountSecretFields @@ -684,12 +747,12 @@ async function buildSnowflakeServiceAccountSecret( } if (fields.apiToken) { throw new ServiceAccountSecretError( - 'Send either a programmatic access token or a keyless configuration, not both' + 'Send either a programmatic access token or a connection configuration, not both' ) } if (!fields.owner) throw new ServiceAccountSecretError('Snowflake credential owner is required') try { - const result = await verifyAndEncryptSnowflakeKeylessCredential( + const result = await verifyAndEncryptSnowflakeConnection( fields.serviceAccountJson, fields.owner ) @@ -802,6 +865,12 @@ const SERVICE_ACCOUNT_SECRET_BUILDERS: Record [ + providerId, + databaseConnectionSecretBuilder(providerId), + ]) + ), } /** diff --git a/apps/sim/lib/credentials/snowflake-service-account.server.test.ts b/apps/sim/lib/credentials/snowflake-service-account.server.test.ts index cf7a2f49a79..5e4ac3eb2e2 100644 --- a/apps/sim/lib/credentials/snowflake-service-account.server.test.ts +++ b/apps/sim/lib/credentials/snowflake-service-account.server.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { generateKeyPairSync } from 'node:crypto' +import { createHash, generateKeyPairSync } from 'node:crypto' import { queueTableRows, resetDbChainMock } from '@sim/testing' import { jsonResponse } from '@sim/testing/helpers/http' import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' @@ -9,7 +9,7 @@ import { inputValidationMock, inputValidationMockFns, } from '@sim/testing/mocks/input-validation.mock' -import { createLocalJWKSet, jwtVerify } from 'jose' +import { createLocalJWKSet, decodeJwt, jwtVerify } from 'jose' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/encryption', () => encryptionMock) @@ -19,6 +19,9 @@ import { credential } from '@sim/db/schema' import { env } from '@/lib/core/config/env' import { resolveSnowflakeCredentialToken, + SnowflakeCredentialError, + storedSnowflakeReconnectFields, + verifyAndEncryptSnowflakeConnection, verifyAndEncryptSnowflakeKeylessCredential, } from '@/lib/credentials/snowflake-service-account.server' import { @@ -45,7 +48,7 @@ function setEnv(key: string, value: string | undefined) { * and the JWT verifies against Sim's published keys with Snowflake's audience, * Sim's issuer, an `iat`, and the subject a service user was created with. */ -function snowflakeAccountTrusting(trustedSubject: string) { +function snowflakeAccountTrusting(trustedSubject: string, audience = 'snowflakecomputing.com') { secureFetch.mockImplementation( async (_url: string, init: { headers: Record }) => { const authorization = init.headers.Authorization ?? '' @@ -59,7 +62,7 @@ function snowflakeAccountTrusting(trustedSubject: string) { createLocalJWKSet(await getWorkloadIdentityJwks()), { issuer: getWorkloadIdentityIssuer(), - audience: 'snowflakecomputing.com', + audience, requiredClaims: ['iat'], } ) @@ -203,6 +206,60 @@ describe('saving a keyless Snowflake connection', () => { }) }) +describe('a keyless Snowflake connection with its own audience', () => { + it('signs in with a token for the custom audience the user lists in OIDC_AUDIENCE_LIST, and keeps it', async () => { + const grant = issueWorkloadIdentitySubject('snowflake', owner, owner.actorUserId) + snowflakeAccountTrusting(grant.subject, 'myorg-myaccount') + + await verifyAndEncryptSnowflakeKeylessCredential( + keylessConfig({ + subject: grant.subject, + subjectProof: grant.subjectProof, + audience: ' myorg-myaccount ', + }), + owner + ) + + const stored = JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0][0]) + expect(stored.config.audience).toBe('myorg-myaccount') + expect(storedSnowflakeReconnectFields(JSON.stringify(stored)).audience).toBe('myorg-myaccount') + }) + + it('stores no audience when the default is submitted, so earlier releases still read the credential', async () => { + const grant = issueWorkloadIdentitySubject('snowflake', owner, owner.actorUserId) + snowflakeAccountTrusting(grant.subject) + + await verifyAndEncryptSnowflakeKeylessCredential( + keylessConfig({ + subject: grant.subject, + subjectProof: grant.subjectProof, + audience: 'snowflakecomputing.com', + }), + owner + ) + + const stored = JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0][0]) + expect(Object.keys(stored.config).sort()).toEqual(['authType', 'host', 'subject']) + expect(storedSnowflakeReconnectFields(JSON.stringify(stored)).audience).toBeUndefined() + }) + + it('refuses another cloud’s default audience before sending a token anywhere', async () => { + const grant = issueWorkloadIdentitySubject('snowflake', owner, owner.actorUserId) + + await expect( + verifyAndEncryptSnowflakeKeylessCredential( + keylessConfig({ + subject: grant.subject, + subjectProof: grant.subjectProof, + audience: 'sts.amazonaws.com', + }), + owner + ) + ).rejects.toThrow(/belongs to another cloud/) + expect(secureFetch).not.toHaveBeenCalled() + }) +}) + describe('getting a Snowflake token for a run', () => { function storedCredential(secret: Record) { resetDbChainMock() @@ -230,6 +287,18 @@ describe('getting a Snowflake token for a run', () => { expect(payload.sub).toBe(subject) }) + it('mints a run token for a keyless credential’s custom audience', async () => { + const subject = 'sim:workspace:ws-1:credential:abcdef0123456789' + storedCredential({ + type: 'snowflake_workload_identity_v1', + config: { authType: 'workload_identity', host: HOST, subject, audience: 'myorg-myaccount' }, + }) + + const resolved = await resolveSnowflakeCredentialToken('cred-1') + + expect(decodeJwt(resolved.accessToken).aud).toBe('myorg-myaccount') + }) + it('resolves a programmatic access token exactly as before, with no token type', async () => { storedCredential({ type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, @@ -245,3 +314,171 @@ describe('getting a Snowflake token for a run', () => { }) }) }) + +describe('a Snowflake key-pair connection', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const PRIVATE_KEY = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string + const FINGERPRINT = `SHA256:${createHash('sha256') + .update(publicKey.export({ type: 'spki', format: 'der' })) + .digest('base64')}` + + function keyPairConfig(fields: Record = {}) { + return JSON.stringify({ + authType: 'key_pair', + host: HOST, + user: 'sim_workflows', + privateKey: PRIVATE_KEY, + ...fields, + }) + } + + /** Snowflake's side of key-pair auth, behind the egress guard: a KEYPAIR_JWT bearer token whose claims and key match. */ + function snowflakeAccountTrustingKey(account: string) { + secureFetch.mockImplementation( + async (_url: string, init: { headers: Record }) => { + const headers = new Headers(init?.headers) + const authorization = headers.get('authorization') ?? '' + if (headers.get('x-snowflake-authorization-token-type') !== 'KEYPAIR_JWT') { + return jsonResponse({ message: 'Invalid token type' }, 401) + } + try { + const { payload } = await jwtVerify(authorization.replace(/^Bearer /, ''), publicKey, { + algorithms: ['RS256'], + issuer: `${account}.SIM_WORKFLOWS.${FINGERPRINT}`, + subject: `${account}.SIM_WORKFLOWS`, + requiredClaims: ['iat', 'exp'], + }) + if ((payload.exp ?? 0) - (payload.iat ?? 0) > 3600) throw new Error('lifetime too long') + } catch { + return jsonResponse({ code: '390144', message: 'JWT token is invalid' }, 401) + } + return jsonResponse({ data: [['SIM_WORKFLOWS', account, 'ANALYST']] }) + } + ) + } + + it('signs in with a JWT naming the account, the uppercased user, and the key fingerprint', async () => { + snowflakeAccountTrustingKey('MYORG-MYACCOUNT') + + const saved = await verifyAndEncryptSnowflakeConnection(keyPairConfig(), { + organizationId: 'org-1', + actorUserId: 'user-1', + }) + + expect(saved.displayName).toBe('SIM_WORKFLOWS (MYORG-MYACCOUNT)') + expect(saved.newWorkloadIdentitySubject).toBeUndefined() + const stored = JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0][0]) + expect(stored).toMatchObject({ + type: 'snowflake_key_pair_v1', + config: { + authType: 'key_pair', + host: HOST, + user: 'sim_workflows', + privateKey: PRIVATE_KEY.trim(), + }, + }) + expect(new URL(String(secureFetch.mock.calls[0][0])).href).toBe( + `https://${HOST}/api/v2/statements` + ) + }) + + it('names a legacy locator account without its region', async () => { + snowflakeAccountTrustingKey('XY12345') + + await expect( + verifyAndEncryptSnowflakeConnection( + keyPairConfig({ host: 'xy12345.us-east-2.aws.snowflakecomputing.com' }), + owner + ) + ).resolves.toMatchObject({ displayName: 'SIM_WORKFLOWS (XY12345)' }) + }) + + it.each([ + ['underscore', 'myorg-marketing_test_account.snowflakecomputing.com'], + ['hyphen', 'myorg-marketing-test-account.snowflakecomputing.com'], + ])( + 'names an account whose name has underscores by that name from its %s host', + async (_form, host) => { + snowflakeAccountTrustingKey('MYORG-MARKETING_TEST_ACCOUNT') + + await expect( + verifyAndEncryptSnowflakeConnection(keyPairConfig({ host }), owner) + ).resolves.toMatchObject({ displayName: 'SIM_WORKFLOWS (MYORG-MARKETING_TEST_ACCOUNT)' }) + } + ) + + it('reports a key the user does not hold as a rejected credential', async () => { + snowflakeAccountTrustingKey('MYORG-MYACCOUNT') + const other = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string + + expect( + await codeOf(verifyAndEncryptSnowflakeConnection(keyPairConfig({ privateKey: other }), owner)) + ).toBe('invalid_credentials') + }) + + it.each([ + ['an unreadable key', 'not a key'], + [ + 'an encrypted key', + generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: 'secret', + }) as string, + ], + [ + 'a short RSA key', + generateKeyPairSync('rsa', { modulusLength: 1024 }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string, + ], + [ + 'an EC key', + generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string, + ], + ])('refuses %s before contacting Snowflake', async (_case, key) => { + const saving = verifyAndEncryptSnowflakeConnection(keyPairConfig({ privateKey: key }), owner) + await expect(saving).rejects.toBeInstanceOf(SnowflakeCredentialError) + await expect(saving).rejects.toThrow(/key/i) + expect(secureFetch).not.toHaveBeenCalled() + }) + + it('refuses a host outside Snowflake before signing anything', async () => { + expect( + await codeOf( + verifyAndEncryptSnowflakeConnection(keyPairConfig({ host: 'evil.example.com' }), owner) + ) + ).toMatch(/host/i) + expect(secureFetch).not.toHaveBeenCalled() + }) + + it('signs a fresh JWT for each run with the key-pair token type', async () => { + resetDbChainMock() + queueTableRows(credential, [ + { workspaceId: 'ws-1', organizationId: null, encryptedServiceAccountKey: 'ciphertext' }, + ]) + const secret = { + type: 'snowflake_key_pair_v1', + config: { authType: 'key_pair', host: HOST, user: 'sim_workflows', privateKey: PRIVATE_KEY }, + } + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ decrypted: JSON.stringify(secret) }) + + const resolved = await resolveSnowflakeCredentialToken('cred-1') + + expect(resolved).toMatchObject({ domain: HOST, authStyle: 'snowflake-key-pair' }) + const claims = decodeJwt(resolved.accessToken) + expect(claims).toMatchObject({ + iss: `MYORG-MYACCOUNT.SIM_WORKFLOWS.${FINGERPRINT}`, + sub: 'MYORG-MYACCOUNT.SIM_WORKFLOWS', + }) + expect(storedSnowflakeReconnectFields(JSON.stringify(secret))).toEqual({ authType: 'key_pair' }) + }) +}) diff --git a/apps/sim/lib/credentials/snowflake-service-account.server.ts b/apps/sim/lib/credentials/snowflake-service-account.server.ts index fdf82264b1a..8c288d256bb 100644 --- a/apps/sim/lib/credentials/snowflake-service-account.server.ts +++ b/apps/sim/lib/credentials/snowflake-service-account.server.ts @@ -1,6 +1,8 @@ +import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto' import { db } from '@sim/db' import { credential } from '@sim/db/schema' import { eq } from 'drizzle-orm' +import { SignJWT } from 'jose' import { z } from 'zod' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { @@ -10,8 +12,16 @@ import { import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config' import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors' import { parseTokenServiceAccountSecretBlob } from '@/lib/credentials/token-service-accounts/server' -import { validateSnowflakeWorkloadIdentity } from '@/lib/credentials/token-service-accounts/validators/snowflake' -import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' +import { + validateSnowflakeKeyPair, + validateSnowflakeWorkloadIdentity, +} from '@/lib/credentials/token-service-accounts/validators/snowflake' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + WORKLOAD_IDENTITY_AUDIENCES, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, mintWorkloadIdentityToken, @@ -24,7 +34,9 @@ import { } from '@/lib/credentials/workload-identity/subject' import { normalizeSnowflakeHost, + SNOWFLAKE_KEY_PAIR_AUTH_STYLE, SNOWFLAKE_WORKLOAD_IDENTITY_AUTH_STYLE, + snowflakeAccountIdentifier, } from '@/tools/snowflake/utils' /** @@ -38,11 +50,26 @@ import { * unlike a Databricks federation policy there is no looser claim match that * could let another Sim credential's subject in. * + * Key-pair connections store the user's unencrypted private key instead, and + * Sim signs a short-lived JWT with it for every run + * (https://docs.snowflake.com/en/developer-guide/sql-api/authenticating). + * + * The token's `aud` is `snowflakecomputing.com` unless the credential sets its + * own `audience`, which the user then lists in `OIDC_AUDIENCE_LIST`. + * * Programmatic-access-token credentials share the provider and keep their - * token-paste blob; only the blob type tells the two modes apart. + * token-paste blob; only the blob type tells the three modes apart. */ const SNOWFLAKE_KEYLESS_SECRET_TYPE = 'snowflake_workload_identity_v1' as const +const SNOWFLAKE_KEY_PAIR_SECRET_TYPE = 'snowflake_key_pair_v1' as const + +/** Snowflake honors a key-pair JWT for at most an hour; its own example signs for 59 minutes. */ +const SNOWFLAKE_KEY_PAIR_JWT_TTL_SECONDS = 59 * 60 +/** Snowflake requires at least a 2048-bit RSA key; larger keys are accepted up to this bound. */ +const MIN_RSA_MODULUS_BITS = 2048 +const MAX_RSA_MODULUS_BITS = 4096 +const MAX_PRIVATE_KEY_LENGTH = 16 * 1024 const SNOWFLAKE_WORKLOAD_IDENTITY_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.snowflake /** @@ -75,9 +102,34 @@ const submittedConfigSchema = z host: hostSchema, subject: z.string().min(1).max(512).optional(), subjectProof: z.string().min(1).max(512).optional(), + audience: workloadIdentityAudienceSchema('snowflake').optional(), }) .strict() +/** A Snowflake user name, sent uppercased in the JWT as Snowflake's own drivers do. */ +const userSchema = z + .string() + .trim() + .min(1, 'Snowflake user is required') + .max(255) + .refine((value) => !/[\x00-\x1f\x7f]/.test(value), 'Enter the Snowflake user name') + +const submittedKeyPairSchema = z + .object({ + authType: z.literal('key_pair'), + host: hostSchema, + user: userSchema, + privateKey: z + .string() + .trim() + .min(1, 'Private key is required') + .max(MAX_PRIVATE_KEY_LENGTH, 'Private key is too large'), + }) + .strict() + +/** Picks the mode a submitted configuration is for, so each is parsed by its own strict schema. */ +const submittedModeSchema = z.object({ authType: z.enum(['workload_identity', 'key_pair']) }) + const storedSecretSchema = z .object({ type: z.literal(SNOWFLAKE_KEYLESS_SECRET_TYPE), @@ -86,6 +138,8 @@ const storedSecretSchema = z authType: z.literal('workload_identity'), host: z.string().min(1), subject: z.string().min(1), + /** Absent for the default audience, so earlier releases' strict schema still reads it. */ + audience: storedWorkloadIdentityAudienceSchema, }) .strict(), metadata: z.record(z.string(), z.string()).optional(), @@ -94,6 +148,23 @@ const storedSecretSchema = z type StoredConfig = z.infer['config'] +const storedKeyPairSecretSchema = z + .object({ + type: z.literal(SNOWFLAKE_KEY_PAIR_SECRET_TYPE), + config: z + .object({ + authType: z.literal('key_pair'), + host: z.string().min(1), + user: z.string().min(1), + privateKey: z.string().min(1), + }) + .strict(), + metadata: z.record(z.string(), z.string()).optional(), + }) + .strict() + +type StoredKeyPairConfig = z.infer['config'] + /** A keyless Snowflake configuration Sim can name the fix for; surfaced to the user as-is. */ export class SnowflakeCredentialError extends Error { constructor(message: string) { @@ -102,16 +173,27 @@ export class SnowflakeCredentialError extends Error { } } -/** The auth mode a decrypted Snowflake secret holds: keyless, a token, or undefined when neither. */ -export function storedSnowflakeAuthType( - decrypted: string -): 'workload_identity' | 'programmatic_access_token' | undefined { +/** + * The auth mode and custom keyless audience a decrypted Snowflake secret holds, for a + * reconnect to reopen on: empty when it is none of the three modes, and no audience for + * one that uses the default. + */ +export function storedSnowflakeReconnectFields(decrypted: string): { + authType?: 'workload_identity' | 'key_pair' | 'programmatic_access_token' + audience?: string +} { try { - if (storedSecretSchema.safeParse(JSON.parse(decrypted)).success) return 'workload_identity' + const parsed: unknown = JSON.parse(decrypted) + const keyless = storedSecretSchema.safeParse(parsed) + if (keyless.success) { + const { audience } = keyless.data.config + return { authType: 'workload_identity', ...(audience ? { audience } : {}) } + } + if (storedKeyPairSecretSchema.safeParse(parsed).success) return { authType: 'key_pair' } parseTokenServiceAccountSecretBlob(decrypted, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID) - return 'programmatic_access_token' + return { authType: 'programmatic_access_token' } } catch { - return undefined + return {} } } @@ -120,16 +202,76 @@ function readStoredSubject(storedSecret: Record | null | undefi return parsed.success ? parsed.data.config.subject : undefined } -function mintSnowflakeToken(subject: string, owner: WorkloadIdentityOwner): Promise { +function mintSnowflakeToken( + config: Pick, + owner: WorkloadIdentityOwner +): Promise { return mintWorkloadIdentityToken({ - subject, - audience: SNOWFLAKE_WORKLOAD_IDENTITY_AUDIENCE, + subject: config.subject, + audience: config.audience ?? SNOWFLAKE_WORKLOAD_IDENTITY_AUDIENCE, owner, ttlSeconds: SNOWFLAKE_TOKEN_TTL_SECONDS, }) } -export interface SnowflakeKeylessSecret { +/** The account and user a key-pair JWT names, both uppercased. */ +function snowflakeQualifiedUser(host: string, user: string): string { + return `${snowflakeAccountIdentifier(host)}.${user.toUpperCase()}` +} + +/** + * Loads the private key a key-pair connection signs with, refusing one + * Snowflake would reject anyway so the person sees what to fix before + * anything is sent. + */ +function loadSnowflakePrivateKey(privateKeyPem: string): KeyObject { + let key: KeyObject + try { + key = createPrivateKey(privateKeyPem) + } catch { + throw new SnowflakeCredentialError( + 'The private key is not a readable, unencrypted PEM private key (-----BEGIN PRIVATE KEY-----).' + ) + } + if (key.asymmetricKeyType !== 'rsa') { + throw new SnowflakeCredentialError( + 'Snowflake key-pair authentication needs an RSA private key.' + ) + } + const bits = key.asymmetricKeyDetails?.modulusLength ?? 0 + if (bits < MIN_RSA_MODULUS_BITS || bits > MAX_RSA_MODULUS_BITS) { + throw new SnowflakeCredentialError( + `The RSA private key is ${bits}-bit; use a 2048- to 4096-bit key.` + ) + } + return key +} + +/** + * Signs the JWT a key-pair connection presents: `iss` is + * `ACCOUNT.USER.SHA256:`, `sub` is `ACCOUNT.USER`, and + * the fingerprint is the base64 SHA-256 of the DER public key, as Snowflake's + * `RSA_PUBLIC_KEY_FP` shows it. + */ +function signSnowflakeKeyPairJwt( + config: Pick +) { + const key = loadSnowflakePrivateKey(config.privateKey) + const fingerprint = createHash('sha256') + .update(createPublicKey(key).export({ type: 'spki', format: 'der' })) + .digest('base64') + const qualifiedUser = snowflakeQualifiedUser(config.host, config.user) + const issuedAt = Math.floor(Date.now() / 1000) + return new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(`${qualifiedUser}.SHA256:${fingerprint}`) + .setSubject(qualifiedUser) + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + SNOWFLAKE_KEY_PAIR_JWT_TTL_SECONDS) + .sign(key) +} + +export interface SnowflakeConnectionSecret { encryptedServiceAccountKey: string displayName: string auditMetadata: Record @@ -149,7 +291,7 @@ export interface SnowflakeKeylessSecret { export async function verifyAndEncryptSnowflakeKeylessCredential( serialized: string, owner: WorkloadIdentityCredentialOwner -): Promise { +): Promise { const parsed = parseServiceAccountConfig( serialized, submittedConfigSchema, @@ -159,7 +301,7 @@ export async function verifyAndEncryptSnowflakeKeylessCredential( if (!(await isWorkloadIdentityConfigured())) { throw new WorkloadIdentityNotConfiguredError('Use a programmatic access token.') } - const { host, subject: submittedSubject, subjectProof } = parsed.data + const { host, subject: submittedSubject, subjectProof, audience } = parsed.data const subject = bindWorkloadIdentitySubject({ provider: 'snowflake', owner, @@ -168,14 +310,16 @@ export async function verifyAndEncryptSnowflakeKeylessCredential( subjectProof, }) + const audienceField = storedAudienceField(audience, SNOWFLAKE_WORKLOAD_IDENTITY_AUDIENCE) const validation = await validateSnowflakeWorkloadIdentity({ domain: host, - subjectToken: await mintSnowflakeToken(subject, owner), + subjectToken: await mintSnowflakeToken({ subject, ...audienceField }, owner), }) const config: StoredConfig = { authType: 'workload_identity', host: validation.normalizedDomain ?? host, subject, + ...audienceField, } const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) const { encrypted } = await encryptSecret( @@ -196,18 +340,77 @@ export async function verifyAndEncryptSnowflakeKeylessCredential( } } +/** + * Validates and saves a configured (non-token) Snowflake connection: keyless + * workload identity, or a key pair. The configuration's `authType` picks the + * mode, and each mode is then parsed by its own strict schema. + */ +export async function verifyAndEncryptSnowflakeConnection( + serialized: string, + owner: WorkloadIdentityCredentialOwner +): Promise { + const mode = parseServiceAccountConfig(serialized, submittedModeSchema, 'Snowflake connection') + if (!mode.success) throw new SnowflakeCredentialError(mode.message) + return mode.data.authType === 'key_pair' + ? verifyAndEncryptSnowflakeKeyPairCredential(serialized) + : verifyAndEncryptSnowflakeKeylessCredential(serialized, owner) +} + +/** + * Validates a key-pair configuration, proves Snowflake accepts a JWT signed + * with its key for its user by running the identity statement, and returns the + * encrypted blob holding the key. No workload identity is involved, so it + * works for organization credentials and on deployments that cannot sign. + */ +async function verifyAndEncryptSnowflakeKeyPairCredential( + serialized: string +): Promise { + const parsed = parseServiceAccountConfig( + serialized, + submittedKeyPairSchema, + 'Snowflake connection' + ) + if (!parsed.success) throw new SnowflakeCredentialError(parsed.message) + const { host, user, privateKey } = parsed.data + const validation = await validateSnowflakeKeyPair({ + domain: host, + jwt: await signSnowflakeKeyPairJwt({ host, user, privateKey }), + }) + const config: StoredKeyPairConfig = { + authType: 'key_pair', + host: validation.normalizedDomain ?? host, + user, + privateKey, + } + const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) + const { encrypted } = await encryptSecret( + JSON.stringify({ + type: SNOWFLAKE_KEY_PAIR_SECRET_TYPE, + config, + metadata: { ...validation.storedMetadata, ...principalMetadata }, + }) + ) + return { + encryptedServiceAccountKey: encrypted, + displayName: validation.displayName, + auditMetadata: { ...validation.auditMetadata, ...principalMetadata }, + principal: validation.principal, + } +} + /** * Resolves an executor-authorized Snowflake credential (the executor already * checked the caller may use it) to the token and account host the tools send. * A programmatic access token resolves exactly as every token-paste credential - * does; a keyless credential mints a token for its own subject and says so in - * `authStyle`, which is the only thing that switches the tools' token type. + * does; a keyless credential mints a token for its own subject, and a key-pair + * credential signs a JWT with its key, each saying so in `authStyle`, which is + * the only thing that switches the tools' token type. */ export async function resolveSnowflakeCredentialToken(credentialId: string): Promise<{ accessToken: string domain?: string providerWorkspaceId?: string - authStyle?: typeof SNOWFLAKE_WORKLOAD_IDENTITY_AUTH_STYLE + authStyle?: typeof SNOWFLAKE_WORKLOAD_IDENTITY_AUTH_STYLE | typeof SNOWFLAKE_KEY_PAIR_AUTH_STYLE }> { const [row] = await db .select({ @@ -222,7 +425,16 @@ export async function resolveSnowflakeCredentialToken(credentialId: string): Pro throw new Error('Token service account secret not found') } const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) - const keyless = storedSecretSchema.safeParse(JSON.parse(decrypted)) + const stored: unknown = JSON.parse(decrypted) + const keyPair = storedKeyPairSecretSchema.safeParse(stored) + if (keyPair.success) { + return { + accessToken: await signSnowflakeKeyPairJwt(keyPair.data.config), + domain: keyPair.data.config.host, + authStyle: SNOWFLAKE_KEY_PAIR_AUTH_STYLE, + } + } + const keyless = storedSecretSchema.safeParse(stored) if (!keyless.success) { const secret = parseTokenServiceAccountSecretBlob( decrypted, @@ -234,13 +446,13 @@ export async function resolveSnowflakeCredentialToken(credentialId: string): Pro providerWorkspaceId: secret.providerWorkspaceId, } } - const { host, subject } = keyless.data.config + const { config } = keyless.data const owner: WorkloadIdentityOwner = row.workspaceId ? { workspaceId: row.workspaceId } : { organizationId: row.organizationId ?? undefined } return { - accessToken: await mintSnowflakeToken(subject, owner), - domain: host, + accessToken: await mintSnowflakeToken(config, owner), + domain: config.host, authStyle: SNOWFLAKE_WORKLOAD_IDENTITY_AUTH_STYLE, } } diff --git a/apps/sim/lib/credentials/token-request-resilience.ts b/apps/sim/lib/credentials/token-request-resilience.ts new file mode 100644 index 00000000000..6d8ed310c42 --- /dev/null +++ b/apps/sim/lib/credentials/token-request-resilience.ts @@ -0,0 +1,141 @@ +import { interruptibleSleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +/** + * How a failed token request is retried: + * - `permanent`: the provider refused the credentials or the account (or the failure is not a + * provider answer at all); asking again cannot help until the secret changes. + * - `throttled`: a 429, or a 503 with a `Retry-After`; the provider is rate limiting the caller. + * - `transient`: any other outage (5xx, timeout, network failure, malformed token response). + */ +export type TokenRequestFailureKind = 'permanent' | 'throttled' | 'transient' + +export interface TokenRequestFailure { + kind: TokenRequestFailureKind + /** Failures of this kind in a row, this one included; 1 for a permanent failure. */ + consecutiveFailures: number + /** How long the failure is replayed before the provider is asked again. */ + replayMs: number +} + +/** + * A token with less than this left is treated as expired and never served, so clock skew and a + * call already in flight cannot carry it past its expiry. + */ +export const TOKEN_EXPIRY_MARGIN_MS = 30_000 + +/** + * How long a remembered failure outlives its replay, so the next failure continues the streak + * and backs off longer. A failure after a quieter gap starts a new streak. + */ +export const TOKEN_FAILURE_STREAK_RETENTION_MS = 5 * 60_000 + +/** A refusal of the credentials is replayed this long, so a revoked secret does not hammer the provider. */ +const PERMANENT_FAILURE_REPLAY_MS = 30_000 +/** + * A provider's `Retry-After` is honored for at most five minutes. Microsoft Entra ID, the most + * explicit documented case, throttles client-credentials token requests with `Retry-After: 60`; + * five minutes leaves room for per-minute provider windows while a proxy or a misbehaving provider + * answering with hours cannot lock every workflow on an application out for that long. + */ +const RETRY_AFTER_BOUNDS = { baseMs: 1_000, maxMs: 5 * 60_000 } as const +/** + * A throttled answer that names no `Retry-After` is replayed for at least a minute, doubling while + * throttling continues, up to the five-minute `Retry-After` cap. Token endpoints document their + * limits per minute (Vanta: 5 tokens and 25 token requests a minute), so asking again sooner lands + * in the window that was just exhausted. + */ +const THROTTLED_BACKOFF = { baseMs: 60_000, maxMs: RETRY_AFTER_BOUNDS.maxMs } as const +/** + * An outage is retried no sooner than 5 s after it (Entra's resilience guidance: without a + * `Retry-After`, the first back-off retry at least 5 s after the response), doubling with jitter + * while it continues, up to 30 s: the flat replay every failure used to get, so no outage is + * replayed longer than before. + */ +const OUTAGE_BACKOFF = { baseMs: 5_000, maxMs: 30_000 } as const +/** The pause before the one in-request retry: MSAL's documented one-second retry-once. */ +const IN_REQUEST_RETRY_DELAY = { baseMs: 1_000, maxMs: 1_000 } as const +/** + * A failed attempt is retried only when it failed this quickly. A timed-out attempt (10 s) is + * never retried in-request, and a retried exchange holding a coordinated application lock stays + * within the 15 s its waiters allow: 2 s, the 1.2 s pause, and one more 10 s attempt. + */ +const IN_REQUEST_RETRY_BUDGET_MS = 2_000 + +/** Whether a token request failure with this code is a refusal that asking again cannot fix. */ +export function isPermanentTokenFailureCode(code: string): boolean { + return code !== 'provider_unavailable' +} + +/** Classifies a failed token request; see {@link TokenRequestFailureKind}. */ +function classifyTokenRequestFailure(error: unknown): TokenRequestFailureKind { + if (!(error instanceof TokenServiceAccountValidationError)) return 'permanent' + if (isPermanentTokenFailureCode(error.code)) return 'permanent' + return error.status === 429 || (error.status === 503 && error.retryAfterMs !== undefined) + ? 'throttled' + : 'transient' +} + +/** Exponential backoff with ±20% jitter, kept within `bounds` so jitter never undercuts the floor. */ +function boundedBackoff(attempt: number, bounds: { baseMs: number; maxMs: number }): number { + return Math.min(Math.max(backoffWithJitter(attempt, null, bounds), bounds.baseMs), bounds.maxMs) +} + +/** + * Decides how long a failed token request is replayed. A remembered failure another layer is + * replaying (`replayForMs`) is replayed exactly as long as that layer still does. Otherwise a + * provider's `Retry-After` is honored within 1 s to 5 min; without one, a throttled answer backs + * off from 1 min to 5 min, an outage from 5 s to 30 s, and a refusal is replayed 30 s. + * `previous` is the remembered failure immediately before this one, if any: the backoff grows only + * while failures of the same kind continue, so a throttled answer after an outage starts its own + * streak at a minute instead of inheriting the outage's count. + */ +export function assessTokenRequestFailure( + error: unknown, + previous?: Pick +): TokenRequestFailure { + const kind = classifyTokenRequestFailure(error) + const consecutiveFailures = + kind !== 'permanent' && previous?.kind === kind ? previous.consecutiveFailures + 1 : 1 + const replay = (replayMs: number): TokenRequestFailure => ({ + kind, + consecutiveFailures, + replayMs, + }) + if (error instanceof TokenServiceAccountValidationError) { + if (error.replayForMs !== undefined) return replay(Math.max(error.replayForMs, 0)) + if (error.retryAfterMs !== undefined && kind !== 'permanent') { + return replay(backoffWithJitter(1, error.retryAfterMs, RETRY_AFTER_BOUNDS)) + } + } + if (kind === 'permanent') return replay(PERMANENT_FAILURE_REPLAY_MS) + if (kind === 'throttled') return replay(boundedBackoff(consecutiveFailures, THROTTLED_BACKOFF)) + return replay(boundedBackoff(consecutiveFailures, OUTAGE_BACKOFF)) +} + +/** + * Runs a token request, retrying a transient failure once after about a second when the failed + * attempt was quick (see {@link IN_REQUEST_RETRY_BUDGET_MS}). Throttled and permanent failures, + * and an aborted `signal`, are never retried. + */ +export async function requestTokenWithRetry( + request: () => Promise, + signal?: AbortSignal +): Promise { + const startedAt = Date.now() + try { + return await request() + } catch (error) { + signal?.throwIfAborted() + if ( + classifyTokenRequestFailure(error) !== 'transient' || + Date.now() - startedAt > IN_REQUEST_RETRY_BUDGET_MS + ) { + throw error + } + await interruptibleSleep(backoffWithJitter(1, null, IN_REQUEST_RETRY_DELAY), signal) + signal?.throwIfAborted() + return request() + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts index b4ed87ae696..59db8694ec7 100644 --- a/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts +++ b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts @@ -1,17 +1,21 @@ import { + INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID, isTokenServiceAccountProviderId, LINEAR_SERVICE_ACCOUNT_PROVIDER_ID, + SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID, SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, type TokenServiceAccountProviderId, } from '@/lib/credentials/token-service-accounts/descriptors' +import { mintInfisicalUniversalAuthToken } from '@/lib/credentials/token-service-accounts/grants/infisical' import { mintLinearClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/linear' +import { mintServiceNowClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/servicenow' import { mintShopifyClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/shopify' /** What a token provider's client-credentials grant needs to mint an access token. */ export interface TokenClientCredentialFields { clientId: string clientSecret: string - /** The normalized account host, for providers whose token endpoint is per account (Shopify). */ + /** The account host or instance URL, for providers whose token endpoint is per account (Shopify, Infisical, ServiceNow). */ domain?: string } @@ -37,6 +41,8 @@ const TOKEN_CLIENT_CREDENTIAL_MINTERS: Partial< > = { [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: mintShopifyClientCredentialsToken, [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: mintLinearClientCredentialsToken, + [INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID]: mintInfisicalUniversalAuthToken, + [SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID]: mintServiceNowClientCredentialsToken, } export function getTokenClientCredentialMinter( diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index 0113b9495d5..f7834764ea5 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -12,6 +12,15 @@ * `@/lib/credentials/token-service-accounts/server`. */ +import type { ClientCredentialAccountOption } from '@/lib/credentials/client-credential-accounts/descriptors' +import { DEFAULT_HEX_HOST, HEX_HOST_PATTERN } from '@/tools/hex/utils' +import { LANGSMITH_REGIONS } from '@/tools/langsmith/utils' +import { LAUNCHDARKLY_INSTANCES } from '@/tools/launchdarkly/utils' +import { NEW_RELIC_REGIONS } from '@/tools/new_relic/utils' +import { SENDGRID_REGIONS } from '@/tools/sendgrid/utils' +import { SENTRY_REGIONS } from '@/tools/sentry/utils' +import { SQUARE_ENVIRONMENTS } from '@/tools/square/types' + /** Discriminator stored inside every encrypted token service-account secret blob. */ export const TOKEN_SERVICE_ACCOUNT_SECRET_TYPE = 'token_service_account' as const @@ -26,15 +35,50 @@ export interface TokenServiceAccountField { secret: boolean /** Preserve exact password bytes instead of normalizing surrounding whitespace. */ preserveWhitespace?: boolean + /** + * The token spans several lines (a PEM private key), so the connect form renders a multi-line + * control that keeps its line breaks instead of a single-line input. + */ + multiline?: boolean /** * The value is a URL whose scheme and path matter (a self-hosted instance or * deployment URL), so the connect form sends it as typed and the provider's * check normalizes it. Other domains are reduced to a bare host first. */ url?: boolean + /** + * The connection can be saved without this field (a workspace ID only one token type needs), + * so the connect form, the create body, and the stored secret do not require it; the provider's + * check decides when it must be set. Applies to a domain or workspace field in pasted-token + * mode; the token itself is always required. + */ + optional?: boolean + /** + * Only on an `optional` field: the `domain` value that makes it required after all (Railway's + * workspace ID once the token type is `workspace`), so the connect form blocks a submit the + * provider's check would reject. Compared case-insensitively against the typed domain. + */ + requiredForDomain?: string + /** + * Auth methods this field belongs to, for a provider that also offers client credentials: + * `token` is the pasted-token sign-in and `client_credentials` the grant. A mode not listed + * neither shows, requires, nor stores it (ServiceNow's username, which a minted token replaces). + * Absent means the field belongs to every mode. + */ + requiredForAuthMethods?: readonly TokenServiceAccountAuthMethod[] /** Soft-format hint shown while the current value doesn't match `hintPattern`. */ hintPattern?: RegExp hintMessage?: string + /** Fixed value set (a region or instance), rendered by the connect modal as a dropdown. */ + options?: ReadonlyArray + /** + * Only on the `domain` field (a region, instance, or host): the value the connection takes when + * none is given. It is also what a credential saved before the field existed resolves to, and + * what a reconnect that leaves the field blank keeps when nothing is stored, so adding the field + * leaves existing credentials, and API callers that omit it, on the host they used before. A + * field with a default is not required. + */ + defaultValue?: string } export interface TokenServiceAccountDescriptor { @@ -69,6 +113,13 @@ export interface TokenServiceAccountDescriptor { * token route surfaces this so tool header builders can switch schemes. */ authStyle?: 'bearer' | 'x-api-token' + /** + * The pasted secret is a password whenever the connection also stores a username in + * `providerWorkspaceId` (Splunk, whose token sign-in is off by default): such a credential + * resolves with `authStyle: 'basic'` so its tools send HTTP Basic. One saved without a username + * resolves exactly as a pasted token. + */ + passwordWithUsername?: true /** * A client-credentials grant the provider offers beside the pasted token. A credential saved * with `authMethod: 'client_credentials'` stores the client ID and secret (and `domain`, when @@ -96,11 +147,28 @@ interface TokenServiceAccountClientCredentials { * retyping the secret. */ preferred?: boolean + /** + * `bearer` for a provider whose pasted mode is not a bearer token (ServiceNow's is a Basic + * username and password): the minted token then resolves with this `authStyle`, which its + * tools read to send it as a bearer token. Absent leaves `authStyle` unset, as before. + */ + authStyle?: 'bearer' } /** `authMethod` value selecting the client-credentials mode of a provider whose default is a pasted token (a token provider, or the Atlassian service account). */ export const CLIENT_CREDENTIALS_AUTH_METHOD = 'client_credentials' as const +/** A token provider's sign-in modes: the pasted token (the default) or its client-credentials grant. */ +export type TokenServiceAccountAuthMethod = 'token' | typeof CLIENT_CREDENTIALS_AUTH_METHOD + +/** Whether a descriptor field belongs to the given sign-in mode. */ +export function tokenFieldBelongsToAuthMethod( + field: Pick, + authMethod: TokenServiceAccountAuthMethod +): boolean { + return !field.requiredForAuthMethods || field.requiredForAuthMethods.includes(authMethod) +} + export const HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID = 'hubspot-service-account' as const export const AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID = 'airtable-service-account' as const export const NOTION_SERVICE_ACCOUNT_PROVIDER_ID = 'notion-service-account' as const @@ -232,6 +300,12 @@ export const AGENTPHONE_SERVICE_ACCOUNT_PROVIDER_ID = 'agentphone-service-accoun export const AGENTMAIL_SERVICE_ACCOUNT_PROVIDER_ID = 'agentmail-service-account' as const export const AFFINITY_SERVICE_ACCOUNT_PROVIDER_ID = 'affinity-service-account' as const export const TAVILY_SERVICE_ACCOUNT_PROVIDER_ID = 'tavily-service-account' as const +export const AMPLITUDE_SERVICE_ACCOUNT_PROVIDER_ID = 'amplitude-service-account' as const +export const KALSHI_SERVICE_ACCOUNT_PROVIDER_ID = 'kalshi-service-account' as const +export const SPLUNK_SERVICE_ACCOUNT_PROVIDER_ID = 'splunk-service-account' as const +export const CBINSIGHTS_SERVICE_ACCOUNT_PROVIDER_ID = 'cbinsights-service-account' as const +export const RAILWAY_SERVICE_ACCOUNT_PROVIDER_ID = 'railway-service-account' as const +export const QDRANT_SERVICE_ACCOUNT_PROVIDER_ID = 'qdrant-service-account' as const const SHOPIFY_DOMAIN_HINT_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i @@ -364,6 +438,12 @@ export type TokenServiceAccountProviderId = | typeof AGENTMAIL_SERVICE_ACCOUNT_PROVIDER_ID | typeof AFFINITY_SERVICE_ACCOUNT_PROVIDER_ID | typeof TAVILY_SERVICE_ACCOUNT_PROVIDER_ID + | typeof AMPLITUDE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof KALSHI_SERVICE_ACCOUNT_PROVIDER_ID + | typeof SPLUNK_SERVICE_ACCOUNT_PROVIDER_ID + | typeof CBINSIGHTS_SERVICE_ACCOUNT_PROVIDER_ID + | typeof RAILWAY_SERVICE_ACCOUNT_PROVIDER_ID + | typeof QDRANT_SERVICE_ACCOUNT_PROVIDER_ID | typeof FLOW_SERVICE_ACCOUNT_PROVIDER_ID export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< @@ -779,7 +859,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< placeholder: 'myorg-myaccount.snowflakecomputing.com', secret: false, hintPattern: SNOWFLAKE_HOST_HINT_REGEX, - hintMessage: 'Snowflake account hosts end in .snowflakecomputing.com.', + hintMessage: + 'Snowflake account hosts end in .snowflakecomputing.com, or .snowflakecomputing.cn in China.', }, { id: 'apiToken', @@ -1074,9 +1155,22 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< connectNoun: 'API token', fields: [ { id: 'apiToken', label: 'API token', placeholder: 'Paste your Hex API token', secret: true }, + { + id: 'domain', + label: 'Workspace host', + placeholder: DEFAULT_HEX_HOST, + secret: false, + defaultValue: DEFAULT_HEX_HOST, + hintPattern: HEX_HOST_PATTERN, + hintMessage: + 'The hex.tech host your workspace signs in at: app.hex.tech (default), eu.hex.tech, or your single-tenant host.', + }, ], docsUrl: 'https://docs.sim.ai/integrations/hex', - helpText: 'Sim checks the token by listing projects, so it needs Read projects access.', + helpText: + 'Sim checks the token by listing projects, so it needs Read projects access. Leave the host as app.hex.tech unless your workspace signs in at another hex.tech address, such as eu.hex.tech.', + invalidCredentialsHelp: + 'Hex rejected this token. Check that it is active, has Read projects access, and belongs to the workspace host you entered.', }, [HUGGINGFACE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: HUGGINGFACE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -1137,8 +1231,22 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< placeholder: 'Paste your LangSmith API key', secret: true, }, + { + id: 'domain', + label: 'Region', + placeholder: 'Select a region', + secret: false, + options: LANGSMITH_REGIONS.options, + defaultValue: LANGSMITH_REGIONS.defaultRegion, + hintMessage: + 'us (default), eu, apac, aws-us, or azure-us: the LangSmith region your organization is in.', + }, ], docsUrl: 'https://docs.sim.ai/integrations/langsmith', + helpText: + 'Choose the region shown in the address you sign in at (for example eu.smith.langchain.com); a key only works in its own region.', + invalidCredentialsHelp: + 'LangSmith rejected this API key. Check that it is active and that the region matches the one your organization is in.', }, [LAUNCHDARKLY_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: LAUNCHDARKLY_SERVICE_ACCOUNT_PROVIDER_ID, @@ -1152,8 +1260,22 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< placeholder: 'Paste your LaunchDarkly access token', secret: true, }, + { + id: 'domain', + label: 'Instance', + placeholder: 'Select an instance', + secret: false, + options: LAUNCHDARKLY_INSTANCES.options, + defaultValue: LAUNCHDARKLY_INSTANCES.defaultRegion, + hintMessage: + 'us (default), eu, or federal: the LaunchDarkly instance the token was created in.', + }, ], docsUrl: 'https://docs.sim.ai/integrations/launchdarkly', + helpText: + 'Choose EU or Federal if your account signs in at app.eu.launchdarkly.com or app.launchdarkly.us; a token only works on the instance that issued it.', + invalidCredentialsHelp: + 'LaunchDarkly rejected this token. Check that it is active and that the instance matches the one your account signs in to.', }, [LINQ_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: LINQ_SERVICE_ACCOUNT_PROVIDER_ID, @@ -1308,10 +1430,20 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< placeholder: 'Paste your SendGrid API key', secret: true, }, + { + id: 'domain', + label: 'Region', + placeholder: 'Select a region', + secret: false, + options: SENDGRID_REGIONS.options, + defaultValue: SENDGRID_REGIONS.defaultRegion, + hintMessage: + 'global (default), or eu for a key of an EU regional subuser, which SendGrid serves from api.eu.sendgrid.com.', + }, ], docsUrl: 'https://docs.sim.ai/integrations/sendgrid', helpText: - 'Create a key in SendGrid under Settings > API Keys. A restricted key only works for the actions it is scoped to.', + 'Create a key in SendGrid under Settings > API Keys. A restricted key only works for the actions it is scoped to. Choose EU subuser for a key of an EU regional subuser; Marketing Campaigns actions (contacts and lists) are not available to EU subusers.', }, [SIMILARWEB_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: SIMILARWEB_SERVICE_ACCOUNT_PROVIDER_ID, @@ -1382,13 +1514,25 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< { id: 'apiToken', label: 'Access token', - placeholder: 'Paste your Square production access token', + placeholder: 'Paste your Square access token', secret: true, }, + { + id: 'domain', + label: 'Environment', + placeholder: 'Select an environment', + secret: false, + options: SQUARE_ENVIRONMENTS.options, + defaultValue: SQUARE_ENVIRONMENTS.defaultRegion, + hintMessage: + 'production (default) or sandbox: the Square environment the access token was issued for.', + }, ], docsUrl: 'https://docs.sim.ai/integrations/square', helpText: - 'Use a production access token from your app in the Square Developer Console; sandbox tokens are not supported.', + 'Use an access token from your app in the Square Developer Console, and choose Sandbox for a sandbox token; a token only works in the environment that issued it.', + invalidCredentialsHelp: + 'Square rejected this access token. Check that it is active and that the environment matches the one the token was issued for.', }, [TAILSCALE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: TAILSCALE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -1794,12 +1938,22 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< placeholder: 'Paste your Sentry auth token', secret: true, }, + { + id: 'domain', + label: 'Data storage region', + placeholder: 'Select a region', + secret: false, + options: SENTRY_REGIONS.options, + defaultValue: SENTRY_REGIONS.defaultRegion, + hintMessage: + 'us (default) or de: the data storage location shown in your organization settings.', + }, ], docsUrl: 'https://docs.sim.ai/integrations/sentry', helpText: - 'Use a user auth token or an internal integration token with the scopes your operations need, such as org:read, project:read, project:write, and event:read. The organization slug stays a field on the block.', + 'Use a user auth token or an internal integration token with the scopes your operations need, such as org:read, project:read, project:write, and event:read. Choose EU if your organization stores its data in the EU (de.sentry.io). The organization slug stays a field on the block.', invalidCredentialsHelp: - 'Sentry rejected this token. Check that it was copied in full, has not been revoked, and belongs to sentry.io rather than a self-hosted Sentry.', + 'Sentry rejected this token. Check that it was copied in full, has not been revoked, belongs to sentry.io rather than a self-hosted Sentry, and that the region matches where your organization stores its data.', }, [STRIPE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: STRIPE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -2074,11 +2228,18 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< connectNoun: 'API key', fields: [ { id: 'apiToken', label: 'User API key', placeholder: 'NRAK-...', secret: true }, - { id: 'domain', label: 'Region', placeholder: 'us or eu', secret: false }, + { + id: 'domain', + label: 'Region', + placeholder: 'Select a data center', + secret: false, + options: NEW_RELIC_REGIONS.options, + hintMessage: 'us, eu, or jp: the data center your New Relic account is in.', + }, ], docsUrl: 'https://docs.sim.ai/integrations/new_relic', helpText: - 'Create a User API key (NRAK-...) in New Relic under API keys. Enter us, or eu if your account is in the EU data center.', + 'Create a User API key (NRAK-...) in New Relic under API keys, and choose the data center your account is in (US, EU, or JP).', invalidCredentialsHelp: "New Relic rejected this key. Check that it is a User API key (NRAK-...), not a license or browser key, and that the region matches your account's data center.", }, @@ -2372,6 +2533,15 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< 'Use https://us.infisical.com for US Cloud, https://eu.infisical.com for EU Cloud, or your self-hosted URL. Sim does not renew the token, so it stops working when its Access Token TTL runs out: set a long Access Token TTL and Max TTL on the identity, and leave Access Token Period unset, since a periodic token expires unless it is renewed.', invalidCredentialsHelp: 'Paste a machine identity access token, not a client secret or service token, and make sure the instance URL matches the region the identity lives in.', + clientCredentials: { + preferred: true, + label: 'Universal Auth (client ID and secret)', + tokenLabel: 'Access token', + helpText: + 'Use the Client ID and a Client Secret from the machine identity’s Universal Auth settings. For the instance URL use https://us.infisical.com for US Cloud, https://eu.infisical.com for EU Cloud, or your self-hosted URL. Sim logs in again whenever its token expires, so leave the Client Secret’s usage limit at 0 (unlimited) and Access Token Max Number of Uses at 0, and allow Sim’s outbound addresses if the identity restricts trusted IPs.', + invalidCredentialsHelp: + 'Infisical rejected this client ID and secret. Check that Universal Auth is configured on the machine identity, that the Client Secret has not expired, been revoked, or reached its usage limit, and that the instance URL matches the region the identity lives in.', + }, }, [SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -2477,6 +2647,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< label: 'Username', placeholder: 'Enter the ServiceNow username', secret: false, + requiredForAuthMethods: ['token'], }, { id: 'domain', @@ -2490,12 +2661,211 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< "Use a dedicated integration user with the roles your workflows need and without multi-factor authentication. Enter your instance's https URL, like acme.service-now.com or a custom instance URL. The instance must be reachable from the internet.", invalidCredentialsHelp: 'ServiceNow rejected this username and password. Check the instance URL, and that the user is active, not locked out, and not required to use multi-factor authentication.', + clientCredentials: { + label: 'OAuth client credentials', + tokenLabel: 'Username and password', + authStyle: 'bearer', + helpText: + 'Use the client ID and secret of an inbound OAuth client set to the client credentials grant. An admin must first set the glide.oauth.inbound.client.credential.grant_type.enabled system property to true and give the client an OAuth Application User with the roles your workflows need; requests act as that user.', + invalidCredentialsHelp: + 'ServiceNow rejected these client credentials. Check the instance URL, the client ID and secret, that the glide.oauth.inbound.client.credential.grant_type.enabled system property is true, and that the client has an active OAuth Application User.', + }, + }, + [AMPLITUDE_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: AMPLITUDE_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Amplitude', + tokenNoun: 'secret key', + connectNoun: 'project keys', + fields: [ + { + id: 'apiToken', + label: 'Secret key', + placeholder: 'Paste the project secret key', + secret: true, + }, + { + id: 'providerWorkspaceId', + label: 'API key', + placeholder: 'Paste the project API key', + secret: false, + }, + { + id: 'domain', + label: 'Data residency', + placeholder: 'us or eu', + secret: false, + defaultValue: 'us', + hintPattern: /^(us|eu|US|EU)$/, + hintMessage: 'Enter us or eu, the region your Amplitude project stores data in.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/amplitude', + helpText: + "In Amplitude, open Settings > Projects, choose the project, and copy the API key and secret key from its General tab. Change the data residency to eu only for a project hosted in Amplitude's EU data center.", + invalidCredentialsHelp: + 'Amplitude rejected this API key and secret key. Check that both come from the same project, and that the data residency matches the region the project is hosted in.', + }, + [KALSHI_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: KALSHI_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Kalshi', + tokenNoun: 'private key', + connectNoun: 'API key', + fields: [ + { + id: 'apiToken', + label: 'Private key', + placeholder: '-----BEGIN PRIVATE KEY-----', + secret: true, + multiline: true, + }, + { + id: 'providerWorkspaceId', + label: 'API key ID', + placeholder: 'Paste the API key ID', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/kalshi', + helpText: + 'In Kalshi, open Account Settings > Profile Settings > API Keys and create a key, then paste its key ID and the private key Kalshi shows only once. Ed25519 and RSA keys both work. The key can trade for your account, so share this credential only with people who may place orders.', + invalidCredentialsHelp: + 'Kalshi rejected this key. Check that the key ID and private key belong to the same API key, that the key has not been deleted, and that you pasted the whole private key file.', }, + [SPLUNK_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: SPLUNK_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Splunk', + tokenNoun: 'authentication token', + connectNoun: 'authentication token', + fields: [ + { + id: 'apiToken', + label: 'Authentication token or password', + placeholder: 'Paste a Splunk authentication token, or the password', + secret: true, + preserveWhitespace: true, + }, + { + id: 'providerWorkspaceId', + label: 'Username', + placeholder: 'Only to sign in with a password', + secret: false, + optional: true, + }, + { + id: 'domain', + label: 'Management URL', + placeholder: 'https://splunk.example.com:8089', + secret: false, + url: true, + }, + ], + passwordWithUsername: true, + docsUrl: 'https://docs.sim.ai/integrations/splunk', + helpText: + 'Create a token in Splunk Web under Settings > Tokens and leave the username blank, or enter a username and its password when token authentication is turned off. Enter the management URL with its port (8089 by default). The URL must be reachable from Sim over https with a certificate from a public certificate authority; Sim does not skip certificate checks.', + invalidCredentialsHelp: + 'Splunk rejected these credentials. For a token, check that token authentication is turned on and that the token is enabled and not expired; for a password, check the username and password. Also check that the management URL points at the same deployment.', + }, + [CBINSIGHTS_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: CBINSIGHTS_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'CB Insights', + tokenNoun: 'client secret', + connectNoun: 'API client', + fields: [ + { + id: 'apiToken', + label: 'Client secret', + placeholder: 'Paste the CB Insights client secret', + secret: true, + }, + { + id: 'providerWorkspaceId', + label: 'Client ID', + placeholder: 'Paste the CB Insights client ID', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/cbinsights', + helpText: + 'CB Insights issues the API client ID and secret; contact your CB Insights representative for production credentials. Sim exchanges them for a short-lived access token on each run.', + invalidCredentialsHelp: + 'CB Insights did not authorize this client ID and secret. Check that both were issued together and are still active.', + }, + [RAILWAY_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: RAILWAY_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Railway', + tokenNoun: 'token', + connectNoun: 'token', + fields: [ + { + id: 'apiToken', + label: 'Token', + placeholder: 'Paste a Railway account, workspace, or project token', + secret: true, + }, + { + id: 'domain', + label: 'Token type', + placeholder: 'account, workspace, or project', + secret: false, + hintPattern: /^(account|workspace|project)$/i, + hintMessage: 'Enter account, workspace, or project: the scope you chose for this token.', + }, + { + id: 'providerWorkspaceId', + label: 'Workspace ID', + placeholder: 'Required for a workspace token', + secret: false, + optional: true, + requiredForDomain: 'workspace', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/railway', + helpText: + "Create an account or workspace token in your Railway account settings, or a project token in a project's settings. A workspace token is scoped to one workspace, so enter that workspace's ID as well.", + invalidCredentialsHelp: + 'Railway rejected this token. Check that the token type matches the scope you created it with, and for a workspace token that the workspace ID is the one it was created in.', + }, + [QDRANT_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: QDRANT_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Qdrant', + tokenNoun: 'API key', + connectNoun: 'API key', + fields: [ + { + id: 'apiToken', + label: 'API key', + placeholder: 'Paste a Qdrant database API key', + secret: true, + }, + { + id: 'domain', + label: 'Instance URL', + placeholder: 'https://xyz-example.eu-central.aws.cloud.qdrant.io:6333', + secret: false, + url: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/qdrant', + helpText: + 'For Qdrant Cloud, create a database API key on the cluster and enter the cluster URL with its port (6333). For a self-hosted instance, enter the key set as its service API key and a URL Sim can reach.', + invalidCredentialsHelp: + 'Qdrant rejected this API key. Check that the key belongs to this cluster or instance and has not expired.', + }, +} + +/** + * Whether a create must give the field: every field is required unless it is optional or has a + * default. + */ +export function isTokenServiceAccountFieldRequired(field: TokenServiceAccountField): boolean { + return !field.optional && field.defaultValue === undefined } /** * Required contract fields per token provider in client-credentials mode: the client ID and - * secret replace the token, and every other descriptor field (a store domain) stays required. + * secret replace the token, and every other descriptor field without a default (a store + * domain) stays required unless it belongs only to the pasted-token sign-in. */ export const TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS: Record< string, @@ -2508,7 +2878,13 @@ export const TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS: Record< [ 'clientId', 'clientSecret', - ...descriptor.fields.flatMap((field) => (field.id === 'apiToken' ? [] : [field.id])), + ...descriptor.fields.flatMap((field) => + field.id === 'apiToken' || + !tokenFieldBelongsToAuthMethod(field, CLIENT_CREDENTIALS_AUTH_METHOD) || + !isTokenServiceAccountFieldRequired(field) + ? [] + : [field.id] + ), ], ]) ) @@ -2516,16 +2892,28 @@ export const TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS: Record< /** * Required contract fields per token service-account provider, consumed by the * `createCredentialBodySchema` superRefine so validation errors name the exact - * missing field. Derived from each descriptor's field list. + * missing field. Derived from each descriptor's field list; a field marked `optional` or + * carrying a `defaultValue` is not required. */ export const TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = Object.fromEntries( Object.values(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS).map((descriptor) => [ descriptor.providerId, - descriptor.fields.map((field) => field.id), + descriptor.fields.filter(isTokenServiceAccountFieldRequired).map((field) => field.id), ]) ) +/** + * The region or host a token credential's `domain` takes when none was given or stored: the + * descriptor's `defaultValue`, or `undefined` when it has none. + */ +export function getTokenServiceAccountDomainDefault( + providerId: string | null | undefined +): string | undefined { + return getTokenServiceAccountDescriptor(providerId)?.fields.find((field) => field.id === 'domain') + ?.defaultValue +} + export function isTokenServiceAccountProviderId( value: string | null | undefined ): value is TokenServiceAccountProviderId { diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.test.ts b/apps/sim/lib/credentials/token-service-accounts/errors.test.ts index f12b7bae6e6..5c5f9dc205d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.test.ts @@ -133,6 +133,50 @@ describe('token service-account error helpers', () => { expect(error.code).toBe('provider_unavailable') }) + + it.each([ + [429, '120', 120_000], + [503, '7', 7_000], + ])( + 'carries the Retry-After a %i asked for in seconds', + async (status, retryAfter, retryAfterMs) => { + const res = new Response('slow down', { status, headers: { 'Retry-After': retryAfter } }) + + const error = await expectValidationError(throwForProviderResponse(res, 'self')) + + expect(error.code).toBe('provider_unavailable') + expect(error.retryAfterMs).toBe(retryAfterMs) + } + ) + + it('carries the Retry-After a 429 asked for as an HTTP date', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + const res = new Response('slow down', { + status: 429, + headers: { 'Retry-After': 'Thu, 01 Jan 2026 00:01:30 GMT' }, + }) + + const error = await expectValidationError(throwForProviderResponse(res, 'self')) + + expect(error.retryAfterMs).toBe(90_000) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + [500, '30'], + [401, '30'], + [429, 'soon'], + ])('carries no Retry-After for a %i with Retry-After %s', async (status, retryAfter) => { + const res = new Response('nope', { status, headers: { 'Retry-After': retryAfter } }) + + const error = await expectValidationError(throwForProviderResponse(res, 'self')) + + expect(error.retryAfterMs).toBeUndefined() + }) }) describe('readProviderErrorSnippet', () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 0dff3b071b1..6a6b70f6b24 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from '@sim/utils/errors' +import { parseRetryAfter } from '@sim/utils/retry' import { truncate } from '@sim/utils/string' import { DEFAULT_MAX_ERROR_BODY_BYTES, @@ -26,6 +27,11 @@ export type TokenServiceAccountValidationCode = * asked for a Google Workspace service or to impersonate a Google Workspace user (domain-wide delegation). */ | 'unsupported_use' + /** + * The credential signs in to a different cloud instance than the one this service calls: a + * US Government or China Microsoft app credential picked for a tool that calls the global cloud. + */ + | 'cloud_mismatch' /** * The provider refused this grant for the account the credential names, although the client * itself may be valid: Shopify answers `shop_not_permitted` when the app and the store are not @@ -33,14 +39,38 @@ export type TokenServiceAccountValidationCode = */ | 'grant_not_permitted' +export interface TokenServiceAccountValidationErrorOptions { + /** See {@link TokenServiceAccountValidationError.retryAfterMs}. */ + retryAfterMs?: number + /** See {@link TokenServiceAccountValidationError.replayForMs}. */ + replayForMs?: number +} + export class TokenServiceAccountValidationError extends Error { + /** + * The provider's `Retry-After` on a 429 or 503 answer, in milliseconds, uncapped as the provider + * sent it. Absent when the provider named none. Only ever set from a provider's own answer. + */ + public readonly retryAfterMs?: number + + /** + * Set when this is a remembered failure being replayed rather than a fresh provider answer: how + * much longer, in milliseconds, it is replayed before the provider is asked again. It says when + * the provider will next be asked, not that asking will then succeed (a replayed refusal of the + * credentials carries it too). + */ + public readonly replayForMs?: number + constructor( public readonly code: TokenServiceAccountValidationCode, public readonly status: number, - public readonly logDetail?: Record + public readonly logDetail?: Record, + options: TokenServiceAccountValidationErrorOptions = {} ) { super(code) this.name = 'TokenServiceAccountValidationError' + if (options.retryAfterMs !== undefined) this.retryAfterMs = options.retryAfterMs + if (options.replayForMs !== undefined) this.replayForMs = options.replayForMs } } @@ -127,6 +157,16 @@ export function providerFailureCode( : 'provider_unavailable' } +/** + * The `Retry-After` of a 429 (RFC 6585) or 503 (RFC 9110 section 15.6.4) answer in milliseconds, + * as delay-seconds or an HTTP-date (RFC 9110 section 10.2.3); `undefined` for any other status or + * a missing or unparsable header. Uncapped: the caller that waits decides how long is too long. + */ +export function throttledRetryAfterMs(response: ProviderResponse): number | undefined { + if (response.status !== 429 && response.status !== 503) return undefined + return parseRetryAfter(response.headers.get('retry-after'), Number.POSITIVE_INFINITY) ?? undefined +} + export interface FetchProviderOptions { /** * Validation code thrown when the host does not resolve (`ENOTFOUND` only — @@ -254,7 +294,7 @@ export async function readProviderErrorSnippet(res: ProviderResponse): Promise { if (res.ok) return const body = await readProviderErrorSnippet(res) - throw new TokenServiceAccountValidationError(providerFailureCode(res.status), res.status, { - step, - body, - ...context, - }) + throw new TokenServiceAccountValidationError( + providerFailureCode(res.status), + res.status, + { step, body, ...context }, + { retryAfterMs: throttledRetryAfterMs(res) } + ) } diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/infisical.test.ts b/apps/sim/lib/credentials/token-service-accounts/grants/infisical.test.ts new file mode 100644 index 00000000000..ef9d6ad7e3f --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/infisical.test.ts @@ -0,0 +1,149 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { coordinated } = vi.hoisted(() => ({ coordinated: vi.fn() })) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: coordinated, +})) + +import { mintInfisicalUniversalAuthToken } from '@/lib/credentials/token-service-accounts/grants/infisical' + +const FIELDS = { + clientId: 'identity-client', + clientSecret: 'identity-secret', + domain: 'https://Secrets.Example.com/', +} + +const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation + +describe('Infisical Universal Auth login', () => { + beforeEach(() => { + secureFetch.mockReset() + coordinated.mockReset() + coordinated.mockImplementation( + async (input: { + exchange: (signal?: AbortSignal) => Promise<{ accessToken: string; expiresAt: Date }> + signal?: AbortSignal + }) => { + const exchanged = await input.exchange(input.signal) + return { + accessToken: exchanged.accessToken, + expiresInSeconds: Math.floor((exchanged.expiresAt.getTime() - Date.now()) / 1000), + } + } + ) + }) + + it('logs in with a JSON body at the instance through the configured-endpoint guard', async () => { + secureFetch.mockResolvedValueOnce( + jsonResponse({ + accessToken: 'machine-token', + expiresIn: 7200, + accessTokenMaxTTL: 2_592_000, + tokenType: 'Bearer', + }) + ) + + const minted = await mintInfisicalUniversalAuthToken(FIELDS) + + expect(minted.accessToken).toBe('machine-token') + expect(minted.expiresInSeconds).toBeGreaterThan(7100) + expect(minted.expiresInSeconds).toBeLessThanOrEqual(7200) + const [url, options] = secureFetch.mock.calls[0] + expect(url).toBe('https://secrets.example.com/api/v1/auth/universal-auth/login') + expect(options).toMatchObject({ + profile: 'configuredEndpoint', + method: 'POST', + maxRedirects: 0, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + }) + expect(JSON.parse(options.body)).toEqual({ + clientId: 'identity-client', + clientSecret: 'identity-secret', + }) + expect(coordinated).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'infisical', + application: ['https://secrets.example.com', 'identity-client'], + clientSecret: 'identity-secret', + }) + ) + }) + + it('serves a long-lived token for at most a day so an early revocation is picked up', async () => { + secureFetch.mockResolvedValueOnce( + jsonResponse({ accessToken: 'long-token', expiresIn: 2_592_000, tokenType: 'Bearer' }) + ) + + const minted = await mintInfisicalUniversalAuthToken(FIELDS) + + expect(minted.expiresInSeconds).toBeLessThanOrEqual(86_400) + expect(minted.expiresInSeconds).toBeGreaterThan(86_000) + }) + + it('serves the token of an identity whose access token TTL is 0 (no expiry) for a day', async () => { + secureFetch.mockResolvedValueOnce( + jsonResponse({ accessToken: 'unbounded-token', expiresIn: 0, tokenType: 'Bearer' }) + ) + + const minted = await mintInfisicalUniversalAuthToken(FIELDS) + + expect(minted.accessToken).toBe('unbounded-token') + expect(minted.expiresInSeconds).toBeLessThanOrEqual(86_400) + expect(minted.expiresInSeconds).toBeGreaterThan(86_000) + }) + + it.each([ + ['a missing access token', { expiresIn: 7200 }], + ['a blank access token', { accessToken: ' ', expiresIn: 7200 }], + ['a negative lifetime', { accessToken: 'token', expiresIn: -1 }], + ['a fractional lifetime', { accessToken: 'token', expiresIn: 1.5 }], + ['a non-object body', ['token']], + ])('treats %s as the provider failing, not the credentials', async (_case, body) => { + secureFetch.mockResolvedValueOnce(jsonResponse(body)) + + await expect(mintInfisicalUniversalAuthToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it.each([ + [401, 'invalid_credentials'], + [400, 'invalid_credentials'], + [403, 'invalid_credentials'], + [404, 'invalid_credentials'], + [429, 'provider_unavailable'], + [500, 'provider_unavailable'], + ])('classifies a %s login refusal as %s', async (status, code) => { + secureFetch.mockResolvedValueOnce( + jsonResponse({ statusCode: status, message: 'nope', error: 'Unauthorized' }, status) + ) + + await expect(mintInfisicalUniversalAuthToken(FIELDS)).rejects.toMatchObject({ code, status }) + }) + + it('never logs in to an instance URL carrying credentials or another scheme', async () => { + for (const domain of ['https://user:pass@secrets.example.com', 'ftp://secrets.example.com']) { + await expect(mintInfisicalUniversalAuthToken({ ...FIELDS, domain })).rejects.toMatchObject({ + code: 'site_not_found', + }) + } + expect(secureFetch).not.toHaveBeenCalled() + }) + + it('requires both the client ID and secret before contacting Infisical', async () => { + await expect( + mintInfisicalUniversalAuthToken({ ...FIELDS, clientSecret: '' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + await expect( + mintInfisicalUniversalAuthToken({ ...FIELDS, clientId: ' ' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(secureFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/infisical.ts b/apps/sim/lib/credentials/token-service-accounts/grants/infisical.ts new file mode 100644 index 00000000000..ae89f840c48 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/infisical.ts @@ -0,0 +1,94 @@ +import { normalizeHttpBaseUrl } from '@/lib/core/security/input-validation' +import { + postToConfiguredTokenEndpoint, + readAccessTokenResponse, +} from '@/lib/credentials/client-credentials-grant' +import { resolveCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' +import type { + TokenClientCredentialFields, + TokenClientCredentialMint, +} from '@/lib/credentials/token-service-accounts/client-credentials' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const STEP = 'infisical_universal_auth_login' +/** + * Infisical access tokens default to a 30-day TTL, and an identity's tokens can be revoked before + * then, so the shared token is replaced daily; a shorter `expiresIn` is honored as reported. An + * `expiresIn` of 0 (an access token TTL of 0, which the attach API accepts) is kept for this long + * too: Universal Auth uses 0 for "never expires" on its client secret TTL. + * + * @see https://infisical.com/docs/api-reference/endpoints/universal-auth/attach + * @see https://infisical.com/docs/documentation/platform/identities/universal-auth + */ +const SERVED_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60 + +/** The instance origin the login and every tool call use, exactly as the token check stores it. */ +function instanceBaseUrl(domain: string | undefined): string { + try { + return normalizeHttpBaseUrl(domain) + } catch { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: 'host_validation', + reason: 'instance URL is not a valid http(s) URL', + }) + } +} + +async function loginWithUniversalAuth( + baseUrl: string, + clientId: string, + clientSecret: string, + signal?: AbortSignal +): Promise<{ accessToken: string; expiresAt: Date }> { + const startedAt = Date.now() + const response = await postToConfiguredTokenEndpoint({ + url: `${baseUrl}/api/v1/auth/universal-auth/login`, + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId, clientSecret }), + step: STEP, + signal, + }) + return readAccessTokenResponse(response, { + step: STEP, + label: 'Infisical login response', + startedAt, + maxLifetimeSeconds: SERVED_TOKEN_LIFETIME_SECONDS, + tokenField: 'accessToken', + expiresInField: 'expiresIn', + zeroLifetimeMeansNoExpiry: true, + signal, + }) +} + +/** + * Logs a machine identity in with Universal Auth (its client ID and secret) on the credential's + * instance, US or EU Cloud or self-hosted, so a saved credential no longer depends on a pasted + * access token that expires. Each login issues a new access token and spends one use of a client + * secret that has a usage limit, so every saved credential for one identity on one instance shares + * a single coordinated token instead of logging in per worker. + * + * @see https://infisical.com/docs/api-reference/endpoints/universal-auth/login + * @see https://infisical.com/docs/documentation/platform/identities/universal-auth + */ +export async function mintInfisicalUniversalAuthToken( + fields: TokenClientCredentialFields, + options?: { signal?: AbortSignal } +): Promise { + options?.signal?.throwIfAborted() + const baseUrl = instanceBaseUrl(fields.domain) + const clientId = fields.clientId.trim() + if (!clientId || !fields.clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'client ID and client secret are required', + }) + } + return resolveCoordinatedClientToken({ + namespace: 'infisical', + application: [baseUrl, clientId], + clientSecret: fields.clientSecret, + step: STEP, + exchange: (signal) => loginWithUniversalAuth(baseUrl, clientId, fields.clientSecret, signal), + signal: options?.signal, + }) +} diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/servicenow.test.ts b/apps/sim/lib/credentials/token-service-accounts/grants/servicenow.test.ts new file mode 100644 index 00000000000..141986ea666 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/servicenow.test.ts @@ -0,0 +1,89 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { coordinated } = vi.hoisted(() => ({ coordinated: vi.fn() })) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: coordinated, +})) + +import { mintServiceNowClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/servicenow' + +const FIELDS = { + clientId: 'sn-client', + clientSecret: 'sn-secret', + domain: 'Acme.service-now.com/now/nav/ui', +} + +const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation + +describe('ServiceNow client credentials grant', () => { + beforeEach(() => { + secureFetch.mockReset() + coordinated.mockReset() + coordinated.mockImplementation( + async (input: { + exchange: (signal?: AbortSignal) => Promise<{ accessToken: string; expiresAt: Date }> + signal?: AbortSignal + }) => { + const exchanged = await input.exchange(input.signal) + return { + accessToken: exchanged.accessToken, + expiresInSeconds: Math.floor((exchanged.expiresAt.getTime() - Date.now()) / 1000), + } + } + ) + }) + + it('posts the grant as a form to the instance oauth_token.do through the egress guard', async () => { + secureFetch.mockResolvedValueOnce( + jsonResponse({ access_token: 'sn-token', expires_in: 1799, token_type: 'Bearer' }) + ) + + const minted = await mintServiceNowClientCredentialsToken(FIELDS) + + expect(minted.accessToken).toBe('sn-token') + expect(minted.expiresInSeconds).toBeGreaterThan(1700) + const [url, options] = secureFetch.mock.calls[0] + expect(url).toBe('https://acme.service-now.com/oauth_token.do') + expect(options).toMatchObject({ + profile: 'configuredEndpoint', + method: 'POST', + maxRedirects: 0, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect([...new URLSearchParams(options.body)]).toEqual([ + ['grant_type', 'client_credentials'], + ['client_id', 'sn-client'], + ['client_secret', 'sn-secret'], + ]) + expect(coordinated).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'servicenow', + application: ['https://acme.service-now.com', 'sn-client'], + clientSecret: 'sn-secret', + }) + ) + }) + + it('never sends the secret to a plain-http instance or one carrying credentials', async () => { + for (const domain of ['http://acme.service-now.com', 'https://me:pw@acme.service-now.com']) { + await expect( + mintServiceNowClientCredentialsToken({ ...FIELDS, domain }) + ).rejects.toMatchObject({ code: 'site_not_found' }) + } + expect(secureFetch).not.toHaveBeenCalled() + }) + + it('requires the client ID and secret before contacting the instance', async () => { + await expect( + mintServiceNowClientCredentialsToken({ ...FIELDS, clientSecret: '' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(secureFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/servicenow.ts b/apps/sim/lib/credentials/token-service-accounts/grants/servicenow.ts new file mode 100644 index 00000000000..a0af1b797bc --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/servicenow.ts @@ -0,0 +1,73 @@ +import { exchangeClientCredentialsGrant } from '@/lib/credentials/client-credentials-grant' +import { resolveCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' +import type { + TokenClientCredentialFields, + TokenClientCredentialMint, +} from '@/lib/credentials/token-service-accounts/client-credentials' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { normalizeServiceNowInstanceUrl } from '@/lib/credentials/token-service-accounts/validators/token-checks' + +const STEP = 'servicenow_token_mint' +/** + * ServiceNow's default access token lifespan is 30 minutes and an admin sets it per OAuth + * application, so the reported `expires_in` is honored up to a day. + */ +const MAX_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60 + +/** The instance origin the grant and every tool call use, exactly as the login check stores it. */ +function instanceOrigin(domain: string | undefined): string { + try { + return normalizeServiceNowInstanceUrl(domain) + } catch { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: 'host_validation', + reason: 'instance URL is not an https URL', + }) + } +} + +/** + * Mints an access token for an inbound OAuth client through the client-credentials grant at the + * instance's `oauth_token.do`. The instance must turn the grant on with the + * `glide.oauth.inbound.client.credential.grant_type.enabled` system property, and the token acts as + * the OAuth Application User on the client's registry entry. The instance is any public https host + * (the same rule the username and password login follows), so the request goes through the + * configured-endpoint egress guard. ServiceNow answers a request made while a token is still valid + * with that token, so every saved credential for one client on one instance shares one coordinated + * token. + * + * @see https://www.servicenow.com/docs/r/xanadu/platform-security/authentication/client-credentials.html + * @see https://www.servicenow.com/docs/r/washingtondc/platform-security/authentication/r_OAuthAPIRequestParameters.html + */ +export async function mintServiceNowClientCredentialsToken( + fields: TokenClientCredentialFields, + options?: { signal?: AbortSignal } +): Promise { + options?.signal?.throwIfAborted() + const origin = instanceOrigin(fields.domain) + const clientId = fields.clientId.trim() + if (!clientId || !fields.clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'client ID and client secret are required', + }) + } + return resolveCoordinatedClientToken({ + namespace: 'servicenow', + application: [origin, clientId], + clientSecret: fields.clientSecret, + step: STEP, + exchange: (signal) => + exchangeClientCredentialsGrant({ + url: `${origin}/oauth_token.do`, + clientId, + clientSecret: fields.clientSecret, + step: STEP, + label: 'ServiceNow token response', + maxLifetimeSeconds: MAX_TOKEN_LIFETIME_SECONDS, + egress: 'configuredEndpoint', + signal, + }), + signal: options?.signal, + }) +} diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts index 440cdb0e4da..3672a6878b8 100644 --- a/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts @@ -21,7 +21,12 @@ const MAX_TOKEN_LIFETIME_SECONDS = 86_400 * Shopify answers `shop_not_permitted` when the app and store are in different organizations, * which no secret fixes, and 404 for a store domain that does not exist. */ -function classifyShopifyRefusal(shop: string, status: number, body: string) { +function classifyShopifyRefusal( + shop: string, + status: number, + body: string, + retryAfterMs: number | undefined +) { if (/shop_not_permitted/i.test(body)) { return new TokenServiceAccountValidationError('grant_not_permitted', status, { step: STEP, @@ -32,7 +37,8 @@ function classifyShopifyRefusal(shop: string, status: number, body: string) { return new TokenServiceAccountValidationError( status === 404 ? 'site_not_found' : providerFailureCode(status), status, - { step: STEP, domain: shop, reason: 'provider rejected token exchange' } + { step: STEP, domain: shop, reason: 'provider rejected token exchange' }, + { retryAfterMs } ) } @@ -82,7 +88,8 @@ export async function mintShopifyClientCredentialsToken( dnsFailureCode: 'site_not_found', dnsFailureReason: 'store domain does not resolve', }, - classifyRefusal: (status, body) => classifyShopifyRefusal(shop, status, body), + classifyRefusal: (status, body, retryAfterMs) => + classifyShopifyRefusal(shop, status, body, retryAfterMs), signal, }), signal: options?.signal, diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index af49d437e10..ffb26b19c4a 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -7,6 +7,7 @@ import { AHREFS_SERVICE_ACCOUNT_PROVIDER_ID, AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID, ALGOLIA_SERVICE_ACCOUNT_PROVIDER_ID, + AMPLITUDE_SERVICE_ACCOUNT_PROVIDER_ID, APIFY_SERVICE_ACCOUNT_PROVIDER_ID, APOLLO_SERVICE_ACCOUNT_PROVIDER_ID, ASANA_SERVICE_ACCOUNT_PROVIDER_ID, @@ -20,6 +21,7 @@ import { BUFFER_SERVICE_ACCOUNT_PROVIDER_ID, CALCOM_SERVICE_ACCOUNT_PROVIDER_ID, CALENDLY_SERVICE_ACCOUNT_PROVIDER_ID, + CBINSIGHTS_SERVICE_ACCOUNT_PROVIDER_ID, CHECKR_SERVICE_ACCOUNT_PROVIDER_ID, CIRCLEBACK_SERVICE_ACCOUNT_PROVIDER_ID, CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID, @@ -64,6 +66,7 @@ import { isTokenServiceAccountProviderId, JOTFORM_SERVICE_ACCOUNT_PROVIDER_ID, JUPYTER_SERVICE_ACCOUNT_PROVIDER_ID, + KALSHI_SERVICE_ACCOUNT_PROVIDER_ID, LANGSMITH_SERVICE_ACCOUNT_PROVIDER_ID, LAUNCHDARKLY_SERVICE_ACCOUNT_PROVIDER_ID, LEMLIST_SERVICE_ACCOUNT_PROVIDER_ID, @@ -87,8 +90,10 @@ import { PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID, PROFOUND_SERVICE_ACCOUNT_PROVIDER_ID, PULSE_SERVICE_ACCOUNT_PROVIDER_ID, + QDRANT_SERVICE_ACCOUNT_PROVIDER_ID, QUARTR_SERVICE_ACCOUNT_PROVIDER_ID, QUIVER_SERVICE_ACCOUNT_PROVIDER_ID, + RAILWAY_SERVICE_ACCOUNT_PROVIDER_ID, RB2B_SERVICE_ACCOUNT_PROVIDER_ID, REDUCTO_SERVICE_ACCOUNT_PROVIDER_ID, RESEND_SERVICE_ACCOUNT_PROVIDER_ID, @@ -103,6 +108,7 @@ import { SIXTYFOUR_SERVICE_ACCOUNT_PROVIDER_ID, SMARTLEAD_SERVICE_ACCOUNT_PROVIDER_ID, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, + SPLUNK_SERVICE_ACCOUNT_PROVIDER_ID, SPORTMONKS_SERVICE_ACCOUNT_PROVIDER_ID, SQUARE_SERVICE_ACCOUNT_PROVIDER_ID, STRIPE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -152,6 +158,7 @@ import { validateAgentPhoneServiceAccount, validateAhrefsServiceAccount, validateAlgoliaServiceAccount, + validateAmplitudeServiceAccount, validateApifyServiceAccount, validateApolloServiceAccount, validateAshbyServiceAccount, @@ -162,6 +169,7 @@ import { validateBrowserUseServiceAccount, validateBufferServiceAccount, validateCalendlyServiceAccount, + validateCbInsightsServiceAccount, validateCheckrServiceAccount, validateCirclebackServiceAccount, validateClerkServiceAccount, @@ -196,6 +204,7 @@ import { validateIntercomServiceAccount, validateJotformServiceAccount, validateJupyterServiceAccount, + validateKalshiServiceAccount, validateLangsmithServiceAccount, validateLaunchdarklyServiceAccount, validateLemlistServiceAccount, @@ -215,8 +224,10 @@ import { validatePlanetScaleServiceAccount, validateProfoundServiceAccount, validatePulseServiceAccount, + validateQdrantServiceAccount, validateQuartrServiceAccount, validateQuiverServiceAccount, + validateRailwayServiceAccount, validateRB2BServiceAccount, validateReductoServiceAccount, validateResendServiceAccount, @@ -229,6 +240,7 @@ import { validateSimilarwebServiceAccount, validateSixtyfourServiceAccount, validateSmartleadServiceAccount, + validateSplunkServiceAccount, validateSportmonksServiceAccount, validateSquareServiceAccount, validateStripeServiceAccount, @@ -291,6 +303,11 @@ export interface TokenServiceAccountValidationResult { normalizedDomain?: string /** Normalized account id to persist instead of the raw user input (when collected). */ normalizedWorkspaceId?: string + /** + * When the vendor stops accepting the token, for a vendor that states it at verification. + * Absent when the token never expires or the vendor does not say. + */ + expiresAt?: Date } export type TokenServiceAccountValidator = ( @@ -388,9 +405,11 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [AFFINITY_SERVICE_ACCOUNT_PROVIDER_ID]: validateAffinityServiceAccount, [TAVILY_SERVICE_ACCOUNT_PROVIDER_ID]: validateTavilyServiceAccount, [ALGOLIA_SERVICE_ACCOUNT_PROVIDER_ID]: validateAlgoliaServiceAccount, + [AMPLITUDE_SERVICE_ACCOUNT_PROVIDER_ID]: validateAmplitudeServiceAccount, [ASHBY_SERVICE_ACCOUNT_PROVIDER_ID]: validateAshbyServiceAccount, [AZURE_DEVOPS_SERVICE_ACCOUNT_PROVIDER_ID]: validateAzureDevOpsServiceAccount, [CALENDLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateCalendlyServiceAccount, + [CBINSIGHTS_SERVICE_ACCOUNT_PROVIDER_ID]: validateCbInsightsServiceAccount, [CHECKR_SERVICE_ACCOUNT_PROVIDER_ID]: validateCheckrServiceAccount, [CONVEX_SERVICE_ACCOUNT_PROVIDER_ID]: validateConvexServiceAccount, [DEVIN_SERVICE_ACCOUNT_PROVIDER_ID]: validateDevinServiceAccount, @@ -406,6 +425,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID]: validateInfisicalServiceAccount, [JOTFORM_SERVICE_ACCOUNT_PROVIDER_ID]: validateJotformServiceAccount, [JUPYTER_SERVICE_ACCOUNT_PROVIDER_ID]: validateJupyterServiceAccount, + [KALSHI_SERVICE_ACCOUNT_PROVIDER_ID]: validateKalshiServiceAccount, [LEMLIST_SERVICE_ACCOUNT_PROVIDER_ID]: validateLemlistServiceAccount, [LOGFIRE_SERVICE_ACCOUNT_PROVIDER_ID]: validateLogfireServiceAccount, [MAILGUN_SERVICE_ACCOUNT_PROVIDER_ID]: validateMailgunServiceAccount, @@ -414,10 +434,13 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [PAGERDUTY_SERVICE_ACCOUNT_PROVIDER_ID]: validatePagerDutyServiceAccount, [PLANE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePlaneServiceAccount, [PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePlanetScaleServiceAccount, + [QDRANT_SERVICE_ACCOUNT_PROVIDER_ID]: validateQdrantServiceAccount, + [RAILWAY_SERVICE_ACCOUNT_PROVIDER_ID]: validateRailwayServiceAccount, [RESEND_SERVICE_ACCOUNT_PROVIDER_ID]: validateResendServiceAccount, [ROOTLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateRootlyServiceAccount, [SENTRY_SERVICE_ACCOUNT_PROVIDER_ID]: validateSentryServiceAccount, [SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID]: validateServiceNowServiceAccount, + [SPLUNK_SERVICE_ACCOUNT_PROVIDER_ID]: validateSplunkServiceAccount, [STRIPE_SERVICE_ACCOUNT_PROVIDER_ID]: validateStripeServiceAccount, [SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID]: validateSupabaseServiceAccount, [TELEGRAM_SERVICE_ACCOUNT_PROVIDER_ID]: validateTelegramServiceAccount, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/github.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/github.test.ts index 088cddc504a..4c1e7b0765b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/github.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/github.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { jsonResponse } from '@sim/testing/helpers/http' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { validateGitHubServiceAccount } from '@/lib/credentials/token-service-accounts/validators/token-checks' const TOKEN = 'test-github-token' @@ -61,4 +61,64 @@ describe('GitHub saved personal access token verification', () => { code: 'provider_unavailable', }) }) + + describe('token expiry', () => { + const NOW = new Date('2026-11-11T12:00:00.000Z') + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function userReply(expiration?: string) { + return jsonResponse( + { login: 'octo-bot', id: 1 }, + expiration ? { headers: { 'GitHub-Authentication-Token-Expiration': expiration } } : 200 + ) + } + + it.each([ + ['a timestamp with a UTC offset', '2026-12-01 10:30:00 +0000', '2026-12-01T10:30:00.000Z'], + [ + 'a timestamp with a positive offset', + '2026-12-01 12:30:00 +0200', + '2026-12-01T10:30:00.000Z', + ], + [ + 'a timestamp with a negative offset', + '2026-12-01 03:30:00 -0700', + '2026-12-01T10:30:00.000Z', + ], + ['a timestamp minutes away', '2026-11-11 12:30:00 +0000', '2026-11-11T12:30:00.000Z'], + ['a timestamp marked UTC', '2026-12-01 10:30:00 UTC', '2026-12-01T10:30:00.000Z'], + ])('records the expiry GitHub reports as %s', async (_, header, expected) => { + fetch.mockResolvedValue(userReply(header)) + + const result = await validateGitHubServiceAccount({ apiToken: TOKEN }) + + expect(result.expiresAt?.toISOString()).toBe(expected) + }) + + it.each([ + ['no expiry header (a token that never expires)', undefined], + ['an unreadable expiry', 'next Tuesday'], + ['a zone name other than UTC', '2026-12-01 10:30:00 PST'], + ['a lowercase utc marker', '2026-12-01 10:30:00 utc'], + ['an impossible date', '2026-13-45 10:00:00 +0000'], + ['an offset hour past 23', '2026-12-01 10:30:00 +2400'], + ['an offset minute past 59', '2026-12-01 10:30:00 -0060'], + ['an expiry in the past, contradicting the accepted request', '2026-11-10 12:00:00 +0000'], + ])('records no expiry for %s and still accepts the token', async (_, header) => { + fetch.mockResolvedValue(userReply(header)) + + const result = await validateGitHubServiceAccount({ apiToken: TOKEN }) + + expect(result.displayName).toBe('octo-bot (GitHub)') + expect(result.expiresAt).toBeUndefined() + }) + }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts index 9edffc5060b..7346f3ccbf2 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts @@ -6,7 +6,7 @@ import { inputValidationMock, inputValidationMockFns, } from '@sim/testing/mocks/input-validation.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) @@ -99,4 +99,55 @@ describe('GitLab saved token verification', () => { validateGitLabServiceAccount({ apiToken: 'test-gitlab-token', domain: 'gitlab.com' }) ).rejects.toMatchObject({ code: 'provider_unavailable' }) }) + + describe('token expiry', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-11-11T12:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('records the expiry date as midnight UTC, when GitLab stops accepting the token', async () => { + fetch.mockResolvedValue(jsonResponse({ ...activeToken, expires_at: '2026-12-01' })) + + const result = await validateGitLabServiceAccount({ + apiToken: 'test-gitlab-token', + domain: 'gitlab.com', + }) + + expect(result.expiresAt?.toISOString()).toBe('2026-12-01T00:00:00.000Z') + }) + + it('records an expiry that is less than an hour away', async () => { + vi.setSystemTime(new Date('2026-11-11T23:30:00.000Z')) + fetch.mockResolvedValue(jsonResponse({ ...activeToken, expires_at: '2026-11-12' })) + + const result = await validateGitLabServiceAccount({ + apiToken: 'test-gitlab-token', + domain: 'gitlab.com', + }) + + expect(result.expiresAt?.toISOString()).toBe('2026-11-12T00:00:00.000Z') + }) + + it.each([ + ['a token without an expiry', null], + ['an unreadable expiry', 'soon'], + ['an impossible date', '2027-02-30'], + ['an expiry that already passed, contradicting the accepted token', '2026-11-11'], + ])('records no expiry for %s and still accepts the token', async (_, expiresAt) => { + fetch.mockResolvedValue(jsonResponse({ ...activeToken, expires_at: expiresAt })) + + const result = await validateGitLabServiceAccount({ + apiToken: 'test-gitlab-token', + domain: 'gitlab.com', + }) + + expect(result.displayName).toBe('sim-workflows (gitlab.com)') + expect(result.expiresAt).toBeUndefined() + }) + }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts index e9aabc970b7..9ed727b85ef 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts @@ -20,6 +20,7 @@ import type { TokenServiceAccountValidationResult, TokenServiceAccountValidator, } from '@/lib/credentials/token-service-accounts/server' +import { credibleTokenExpiry } from '@/lib/credentials/vendor-token-expiry' interface TokenCheckRequest { url: string @@ -42,9 +43,21 @@ interface TokenCheck { /** * For a credential whose account id is part of the connection (an org slug, * account SID): returns it trimmed, throwing for a value that could steer the - * request's path or host. + * request's path or host. Returns `undefined` for a sign-in mode that has no + * account id (a ServiceNow OAuth client has no username). */ - workspaceId?: (fields: TokenServiceAccountFields) => string + workspaceId?: (fields: TokenServiceAccountFields) => string | undefined + /** + * The domain a credential takes when none is given (its descriptor field's `defaultValue`). A + * credential on it keeps the bare service name credentials saved before the field existed have, + * and its host failing to resolve is the vendor being unavailable rather than the user's typo. + */ + defaultDomain?: string + /** + * The domain picks one of the vendor's own region hosts from a fixed list, never a host the user + * typed, so one failing to resolve is the vendor being unavailable rather than the user's typo. + */ + domainIsVendorRegion?: boolean /** * The request goes to a host the user chose (self-hosted or white-label), so * it runs through the same SSRF-guarded egress profile as the tools instead @@ -86,6 +99,32 @@ interface TokenCheck { * {@link rejectsKey}. */ provesKey?: (errorBody: unknown) => boolean + /** + * For a vendor that states when the verified token stops working: reads that moment from the + * accepted response, whose `body` is the parsed JSON when {@link acceptsBody} read it. Returns + * `undefined` when the vendor states no expiry or one that cannot be read; the token is still + * accepted either way. + */ + expiresAt?: (accepted: { + headers: ProviderResponse['headers'] + body: unknown + }) => Date | undefined +} + +/** The expiry a check states for an accepted token, when it is credible. */ +function credibleExpiry( + check: TokenCheck, + accepted: { headers: ProviderResponse['headers']; body: unknown } +): Date | undefined { + return credibleTokenExpiry(check.expiresAt?.(accepted)) +} + +/** `accepted` with the expiry the check states, when there is a credible one. */ +function withExpiry( + accepted: TokenServiceAccountValidationResult, + expiresAt: Date | undefined +): TokenServiceAccountValidationResult { + return expiresAt ? { ...accepted, expiresAt } : accepted } const MAX_CHECK_RESPONSE_BYTES = 64 * 1024 @@ -101,7 +140,7 @@ export function readJsonBody(response: ProviderResponse, serviceLabel: string): } /** A domain that must be one of a fixed set (a region or regional host), compared case-insensitively. */ -export function allowedDomain(allowed: ReadonlySet) { +export function allowedDomain(allowed: { has(value: string): boolean }) { return ({ domain }: TokenServiceAccountFields): string => { const value = (domain ?? '').trim().toLowerCase() if (!allowed.has(value)) throw new Error('unknown region') @@ -111,7 +150,7 @@ export function allowedDomain(allowed: ReadonlySet) { /** Runs `normalize` on a typed connection field, rejecting what it refuses as the user's input. */ function normalizedField( - normalize: ((fields: TokenServiceAccountFields) => string) | undefined, + normalize: ((fields: TokenServiceAccountFields) => string | undefined) | undefined, fields: TokenServiceAccountFields, field: 'domain' | 'account id' ): string | undefined { @@ -190,7 +229,8 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid ...(domain === undefined ? {} : { domain }), ...(workspaceId === undefined ? {} : { providerWorkspaceId: workspaceId }), } - const where = domain ?? workspaceId + const typedDomain = domain === check.defaultDomain ? undefined : domain + const where = typedDomain ?? workspaceId const accepted: TokenServiceAccountValidationResult = { displayName: where ? `${check.serviceLabel} (${where})` : check.serviceLabel, principal: null, @@ -214,7 +254,7 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid }, 'verify', // A host built from the typed domain or account id that doesn't exist is the user's typo. - domain === undefined && workspaceId === undefined + (typedDomain === undefined || check.domainIsVendorRegion) && workspaceId === undefined ? undefined : { dnsFailureCode: 'site_not_found' } ) @@ -275,13 +315,22 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid if (!verdict) { throw new TokenServiceAccountValidationError('invalid_credentials', 400, { step: 'verify' }) } + const expiresAt = credibleExpiry(check, { headers: response.headers, body }) if (typeof verdict === 'object') { const name = verdict.displayName - return { ...accepted, displayName: where ? `${name} (${where})` : name } + return withExpiry( + { ...accepted, displayName: where ? `${name} (${where})` : name }, + expiresAt + ) } - return accepted + return withExpiry(accepted, expiresAt) } await response.body?.cancel().catch(() => undefined) - return accepted + return withExpiry( + accepted, + response.ok + ? credibleExpiry(check, { headers: response.headers, body: undefined }) + : undefined + ) } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/snowflake.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/snowflake.test.ts index adce4905ef4..608e0f1ead9 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/snowflake.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/snowflake.test.ts @@ -10,6 +10,7 @@ vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock import { EgressRefusedError } from '@/lib/core/network/transport.server' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { + validateSnowflakeKeyPair, validateSnowflakeServiceAccount, validateSnowflakeWorkloadIdentity, } from '@/lib/credentials/token-service-accounts/validators/snowflake' @@ -117,3 +118,26 @@ describe('validateSnowflakeWorkloadIdentity', () => { ) }) }) + +describe('validateSnowflakeKeyPair', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('sends the signed JWT only through the egress guard', async () => { + inputValidationMockFns.mockSecureFetchWithValidation.mockRejectedValueOnce( + new EgressRefusedError('private address') + ) + + await expectCode( + validateSnowflakeKeyPair({ domain: fields.domain, jwt: 'signed-jwt' }), + 'host_not_allowed', + 400 + ) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/snowflake.ts b/apps/sim/lib/credentials/token-service-accounts/validators/snowflake.ts index 72966f2ea08..439062771da 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/snowflake.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/snowflake.ts @@ -17,6 +17,7 @@ import type { import { buildSnowflakeAuthHeaders, normalizeSnowflakeHost, + SNOWFLAKE_KEY_PAIR_AUTH_STYLE, SNOWFLAKE_WORKLOAD_IDENTITY_AUTH_STYLE, } from '@/tools/snowflake/utils' @@ -48,7 +49,7 @@ function cell(row: Array | undefined, index: number): string | un return typeof value === 'string' && value.trim() ? value.trim() : undefined } -/** Sends the identity statement to the account; the PAT and keyless paths differ only here. */ +/** Sends the identity statement to the account; the PAT path and the Sim-signed paths differ only here. */ type SendIdentityStatement = ( url: string, init: { headers: Record; body: string } @@ -147,6 +148,43 @@ async function verifySnowflakeIdentity( } } +/** + * Sends a Sim-signed token (a key-pair JWT or a keyless subject token) through + * the guarded egress path, so it can neither be redirected nor reach a private + * address the account host happens to resolve to. + */ +const sendThroughEgressGuard: SendIdentityStatement = async (url, init) => { + try { + return await secureFetchWithValidation(url, { + profile: 'configuredEndpoint', + method: 'POST', + headers: init.headers, + body: init.body, + timeout: IDENTITY_FETCH_TIMEOUT_MS, + maxRedirects: 0, + maxResponseBytes: IDENTITY_MAX_RESPONSE_BYTES, + logUrlValidationDetails: false, + }) + } catch (error) { + // A host the egress policy refuses (a private address) is the user's input, not an outage. + if (error instanceof EgressRefusedError) { + throw new TokenServiceAccountValidationError('host_not_allowed', 400, { + step: 'identity_statement', + reason: 'account host not allowed', + }) + } + const notFound = isHostNotFound(error) + throw new TokenServiceAccountValidationError( + notFound ? 'site_not_found' : 'provider_unavailable', + notFound ? 400 : 502, + { + step: 'identity_statement', + reason: notFound ? 'account host does not resolve' : 'account host unreachable', + } + ) + } +} + /** * Validates a Snowflake programmatic access token by running a context-only * statement against the account's SQL API — the same endpoint, headers, and @@ -172,11 +210,27 @@ export async function validateSnowflakeServiceAccount( ) } +/** + * Verifies a key-pair Snowflake connection: the JWT Sim signed with the user's + * private key is sent with the `KEYPAIR_JWT` token type, exactly as the tools + * send it, through the guarded egress path, so Snowflake's answer proves the + * user, key, and account all match. + */ +export async function validateSnowflakeKeyPair(input: { + domain: string + jwt: string +}): Promise { + return verifySnowflakeIdentity( + accountBaseUrl(input.domain), + buildSnowflakeAuthHeaders(input.jwt, SNOWFLAKE_KEY_PAIR_AUTH_STYLE), + sendThroughEgressGuard + ) +} + /** * Verifies a keyless Snowflake connection: the token Sim signed for the * credential's subject is presented as workload identity federation at the - * account host the credential pins, through the guarded egress path, so it can - * neither be redirected nor reach a private address. + * account host the credential pins, through the guarded egress path. */ export async function validateSnowflakeWorkloadIdentity(input: { domain: string @@ -185,36 +239,6 @@ export async function validateSnowflakeWorkloadIdentity(input: { return verifySnowflakeIdentity( accountBaseUrl(input.domain), buildSnowflakeAuthHeaders(input.subjectToken, SNOWFLAKE_WORKLOAD_IDENTITY_AUTH_STYLE), - async (url, init) => { - try { - return await secureFetchWithValidation(url, { - profile: 'configuredEndpoint', - method: 'POST', - headers: init.headers, - body: init.body, - timeout: IDENTITY_FETCH_TIMEOUT_MS, - maxRedirects: 0, - maxResponseBytes: IDENTITY_MAX_RESPONSE_BYTES, - logUrlValidationDetails: false, - }) - } catch (error) { - // A host the egress policy refuses (a private address) is the user's input, not an outage. - if (error instanceof EgressRefusedError) { - throw new TokenServiceAccountValidationError('host_not_allowed', 400, { - step: 'identity_statement', - reason: 'account host not allowed', - }) - } - const notFound = isHostNotFound(error) - throw new TokenServiceAccountValidationError( - notFound ? 'site_not_found' : 'provider_unavailable', - notFound ? 400 : 502, - { - step: 'identity_statement', - reason: notFound ? 'account host does not resolve' : 'account host unreachable', - } - ) - } - } + sendThroughEgressGuard ) } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 363f4e73db1..8ce7f724ef3 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto' import { jsonResponse } from '@sim/testing/helpers/http' import { inputValidationMock, @@ -10,9 +11,11 @@ vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock import { validateAgentMailServiceAccount, validateAlgoliaServiceAccount, + validateAmplitudeServiceAccount, validateAshbyServiceAccount, validateAzureDevOpsServiceAccount, validateBrexServiceAccount, + validateCbInsightsServiceAccount, validateConvexServiceAccount, validateDevinServiceAccount, validateDynatraceServiceAccount, @@ -25,14 +28,18 @@ import { validateInstantlyServiceAccount, validateJotformServiceAccount, validateJupyterServiceAccount, + validateKalshiServiceAccount, validateLogfireServiceAccount, validateMailgunServiceAccount, validateNewRelicServiceAccount, validateOktaServiceAccount, validatePlaneServiceAccount, validatePlanetScaleServiceAccount, + validateQdrantServiceAccount, + validateRailwayServiceAccount, validateResendServiceAccount, validateServiceNowServiceAccount, + validateSplunkServiceAccount, validateSupabaseServiceAccount, validateThriveServiceAccount, validateTinybirdServiceAccount, @@ -267,6 +274,22 @@ describe('New Relic saved User key verification', () => { validateNewRelicServiceAccount({ apiToken: 'NRAK-unknown', domain: 'us' }) ).rejects.toMatchObject({ code: 'invalid_credentials' }) }) + + it.each([ + ['us', 'https://api.newrelic.com/graphql'], + ['EU', 'https://api.eu.newrelic.com/graphql'], + ['jp', 'https://api.jp.newrelic.com/graphql'], + ])('checks a %s key at its data center (%s) and stores the region', async (domain, url) => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ data: { actor: { user: { id: 1 } } } })) + vi.stubGlobal('fetch', fetchMock) + + const result = await validateNewRelicServiceAccount({ apiToken: 'NRAK-valid', domain }) + + expect(new Request(fetchMock.mock.calls[0][0], fetchMock.mock.calls[0][1]).url).toBe(url) + expect(result.normalizedDomain).toBe(domain.toLowerCase()) + }) }) describe('Ashby saved API key verification', () => { @@ -441,6 +464,12 @@ describe('saved credentials whose connection names a host, region, or account', ['Twilio', validateTwilioServiceAccount, { providerWorkspaceId: 'AC123/../Messages' }], ['PlanetScale', validatePlanetScaleServiceAccount, { providerWorkspaceId: 'id:extra' }], ['Gong', validateGongServiceAccount, { providerWorkspaceId: 'key:extra' }], + ['Amplitude', validateAmplitudeServiceAccount, { providerWorkspaceId: 'key', domain: 'mars' }], + [ + 'Amplitude', + validateAmplitudeServiceAccount, + { providerWorkspaceId: 'key:extra', domain: 'us' }, + ], [ 'ServiceNow', validateServiceNowServiceAccount, @@ -509,6 +538,7 @@ describe('saved credentials for a host the user chose', () => { ['Jupyter', validateJupyterServiceAccount], ['Logfire', validateLogfireServiceAccount], ['Plane', validatePlaneServiceAccount], + ['Splunk', validateSplunkServiceAccount], ['Tinybird', validateTinybirdServiceAccount], ['Upstash', validateUpstashServiceAccount], ] as const @@ -693,6 +723,239 @@ describe('Gong saved access key verification', () => { }) }) +describe('Amplitude saved project key verification', () => { + it('lists events on the EU host with the Basic API key and secret key the tools send', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ data: [] })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateAmplitudeServiceAccount({ + apiToken: 'secret-key', + providerWorkspaceId: ' api-key ', + domain: 'EU', + }) + ).resolves.toMatchObject({ normalizedDomain: 'eu', normalizedWorkspaceId: 'api-key' }) + expect(fetch).toHaveBeenCalledWith( + 'https://analytics.eu.amplitude.com/api/2/events/list', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: expect.objectContaining({ Authorization: `Basic ${btoa('api-key:secret-key')}` }), + }) + ) + }) + + it('rejects a key pair Amplitude answers with its invalid-key 403', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Invalid API key', { status: 403 })) + ) + + await expect( + validateAmplitudeServiceAccount({ + apiToken: 'bad', + providerWorkspaceId: 'api-key', + domain: 'us', + }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('refuses a secret key the tools could not encode, before any request', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + validateAmplitudeServiceAccount({ + apiToken: 'sécret', + providerWorkspaceId: 'api-key', + domain: 'us', + }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + }) +}) + +describe('Kalshi saved API key verification', () => { + const ed25519 = () => { + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519') + return { pem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), publicKey } + } + + it('reads the balance with a request signed by the pasted Ed25519 key, as the tools sign', async () => { + const { pem, publicKey } = ed25519() + const fetch = vi.fn().mockResolvedValue(jsonResponse({ balance: 0, portfolio_value: 0 })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateKalshiServiceAccount({ apiToken: pem, providerWorkspaceId: ' key-id ' }) + ).resolves.toMatchObject({ normalizedWorkspaceId: 'key-id' }) + const [url, init] = fetch.mock.calls[0] + expect(url).toBe('https://api.elections.kalshi.com/trade-api/v2/portfolio/balance') + expect(init).toMatchObject({ method: 'GET', redirect: 'error' }) + const headers = init.headers as Record + expect(headers['KALSHI-ACCESS-KEY']).toBe('key-id') + const signed = `${headers['KALSHI-ACCESS-TIMESTAMP']}GET/trade-api/v2/portfolio/balance` + expect( + crypto.verify( + null, + Buffer.from(signed), + publicKey, + Buffer.from(headers['KALSHI-ACCESS-SIGNATURE'], 'base64') + ) + ).toBe(true) + }) + + it('rejects a key Kalshi refuses to authenticate', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ error: { code: 'authentication_error' } }, 401)) + ) + + await expect( + validateKalshiServiceAccount({ apiToken: ed25519().pem, providerWorkspaceId: 'key-id' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it.each([ + ['a value that is not a private key', 'not a key', 'key-id'], + ['a key ID that would break the header', ed25519().pem, 'key id'], + ])('refuses %s before any request', async (_, apiToken, providerWorkspaceId) => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + validateKalshiServiceAccount({ apiToken, providerWorkspaceId }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + }) +}) + +describe('Splunk saved authentication token verification', () => { + const secureFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP + + function reply(response: Response) { + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.7', + originalHostname: 'splunk.example.com', + }) + secureFetch.mockReset().mockResolvedValue(response) + } + + it('reads the token owner on the management URL the tools use, through the guarded egress', async () => { + reply(jsonResponse({ entry: [{ content: { username: 'svc-sim' } }] })) + + await expect( + validateSplunkServiceAccount({ + apiToken: 'splunk-token', + domain: 'splunk.example.com:8089/', + }) + ).resolves.toMatchObject({ normalizedDomain: 'https://splunk.example.com:8089' }) + expect(secureFetch).toHaveBeenCalledWith( + 'https://splunk.example.com:8089/services/authentication/current-context?output_mode=json', + '203.0.113.7', + expect.objectContaining({ + method: 'GET', + maxRedirects: 0, + headers: expect.objectContaining({ Authorization: 'Bearer splunk-token' }), + }) + ) + }) + + it('rejects a token Splunk refuses', async () => { + reply( + jsonResponse({ messages: [{ type: 'WARN', text: 'call not properly authenticated' }] }, 401) + ) + + await expect( + validateSplunkServiceAccount({ apiToken: 'bad', domain: 'https://splunk.example.com:8089' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('does not take a 200 without a user context, such as a proxy page, as proof', async () => { + reply(jsonResponse({ ok: true })) + + await expect( + validateSplunkServiceAccount({ apiToken: 'any', domain: 'https://splunk.example.com:8089' }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) + + it('signs a username and password in with HTTP Basic on the same check, and stores the username', async () => { + reply(jsonResponse({ entry: [{ content: { username: 'svc-sim' } }] })) + + await expect( + validateSplunkServiceAccount({ + apiToken: 'p@ss word', + providerWorkspaceId: ' svc-sim ', + domain: 'https://splunk.example.com:8089', + }) + ).resolves.toMatchObject({ + normalizedDomain: 'https://splunk.example.com:8089', + normalizedWorkspaceId: 'svc-sim', + }) + expect(secureFetch).toHaveBeenCalledWith( + 'https://splunk.example.com:8089/services/authentication/current-context?output_mode=json', + '203.0.113.7', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from('svc-sim:p@ss word').toString('base64')}`, + }), + }) + ) + }) + + it('refuses a username HTTP Basic cannot carry without sending it', async () => { + reply(jsonResponse({ entry: [{ content: { username: 'svc-sim' } }] })) + + await expect( + validateSplunkServiceAccount({ + apiToken: 'password', + providerWorkspaceId: 'svc:sim', + domain: 'https://splunk.example.com:8089', + }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(secureFetch).not.toHaveBeenCalled() + }) +}) + +describe('CB Insights saved API client verification', () => { + it('exchanges the client ID and secret for a token at the authorize endpoint the tools use', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ token: 'access-token' })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateCbInsightsServiceAccount({ apiToken: 'secret', providerWorkspaceId: ' client ' }) + ).resolves.toMatchObject({ normalizedWorkspaceId: 'client' }) + expect(fetch).toHaveBeenCalledWith( + 'https://api.cbinsights.com/v2/authorize', + expect.objectContaining({ + method: 'POST', + redirect: 'error', + body: JSON.stringify({ clientId: 'client', clientSecret: 'secret' }), + }) + ) + }) + + it('rejects a client CB Insights refuses to authorize', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ error: 'invalid credentials' }, 401)) + ) + + await expect( + validateCbInsightsServiceAccount({ apiToken: 'bad', providerWorkspaceId: 'client' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('does not take a success without a token as proof', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}))) + + await expect( + validateCbInsightsServiceAccount({ apiToken: 'secret', providerWorkspaceId: 'client' }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) +}) + describe('ServiceNow saved username and password verification', () => { const secureFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP @@ -851,3 +1114,156 @@ describe('Twilio saved credential verification', () => { } ) }) + +describe('Railway saved token verification', () => { + const RAILWAY_GRAPHQL = 'https://backboard.railway.com/graphql/v2' + + function sentQuery(fetch: ReturnType) { + const [, init] = fetch.mock.calls[0] + return JSON.parse(init.body) as { query: string; variables?: Record } + } + + it('reads the account behind an account token with the Bearer header', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ data: { me: { id: 'u1' } } })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateRailwayServiceAccount({ apiToken: 'acct', domain: ' Account ' }) + ).resolves.toMatchObject({ normalizedDomain: 'account' }) + expect(fetch).toHaveBeenCalledWith( + RAILWAY_GRAPHQL, + expect.objectContaining({ + method: 'POST', + redirect: 'error', + headers: expect.objectContaining({ Authorization: 'Bearer acct' }), + }) + ) + expect(sentQuery(fetch).query).toContain('me') + }) + + it('reads the project behind a project token with the Project-Access-Token header', async () => { + const fetch = vi + .fn() + .mockResolvedValue( + jsonResponse({ data: { projectToken: { projectId: 'p1', environmentId: 'e1' } } }) + ) + vi.stubGlobal('fetch', fetch) + + await expect( + validateRailwayServiceAccount({ apiToken: 'proj', domain: 'project' }) + ).resolves.toMatchObject({ normalizedDomain: 'project' }) + const [, init] = fetch.mock.calls[0] + expect(init.headers).toMatchObject({ 'Project-Access-Token': 'proj' }) + expect(init.headers).not.toHaveProperty('Authorization') + expect(sentQuery(fetch).query).toContain('projectToken') + }) + + it('reads the named workspace for a workspace token, passing its ID as a variable', async () => { + const fetch = vi + .fn() + .mockResolvedValue(jsonResponse({ data: { workspace: { id: 'ws-1', name: 'Acme' } } })) + vi.stubGlobal('fetch', fetch) + + await validateRailwayServiceAccount({ + apiToken: 'team', + domain: 'workspace', + providerWorkspaceId: 'ws-1', + }) + const sent = sentQuery(fetch) + expect(sent.query).toContain('workspace(workspaceId: $workspaceId)') + expect(sent.variables).toEqual({ workspaceId: 'ws-1' }) + }) + + it.each([ + ['a workspace token without its workspace ID', { domain: 'workspace' }], + ['a workspace ID that is not an ID', { domain: 'workspace', providerWorkspaceId: 'a b"c' }], + ['an unknown token type', { domain: 'personal' }], + ])('refuses %s before calling Railway', async (_case, fields) => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + validateRailwayServiceAccount({ apiToken: 'token', ...fields }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + }) + + it('rejects a token Railway answers with a GraphQL authorization error', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse({ data: { me: null }, errors: [{ message: 'Not Authorized' }] }) + ) + ) + + await expect( + validateRailwayServiceAccount({ apiToken: 'bad', domain: 'account' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) +}) + +describe('Qdrant saved API key verification', () => { + const secureFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP + + function reply(response: Response) { + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.9', + originalHostname: 'qdrant.example.com', + }) + secureFetch.mockReset().mockResolvedValue(response) + } + + it('lists collections on the URL the tools use, with the api-key header, through the guarded egress', async () => { + reply(jsonResponse({ result: { collections: [{ name: 'docs' }] }, status: 'ok' })) + + await expect( + validateQdrantServiceAccount({ apiToken: 'qdrant-key', domain: 'qdrant.example.com:6333/' }) + ).resolves.toMatchObject({ normalizedDomain: 'https://qdrant.example.com:6333' }) + expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledWith( + 'https://qdrant.example.com:6333/collections', + 'url', + 'configuredEndpoint', + expect.anything() + ) + expect(secureFetch).toHaveBeenCalledWith( + 'https://qdrant.example.com:6333/collections', + '203.0.113.9', + expect.objectContaining({ + method: 'GET', + maxRedirects: 0, + headers: expect.objectContaining({ 'api-key': 'qdrant-key' }), + }) + ) + }) + + it('rejects a key Qdrant refuses', async () => { + reply(new Response('Must provide an API key or an Authorization bearer token', { status: 401 })) + + await expect( + validateQdrantServiceAccount({ apiToken: 'bad', domain: 'https://qdrant.example.com' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('does not take a 200 without a collection list, such as a proxy page, as proof', async () => { + reply(jsonResponse({ ok: true })) + + await expect( + validateQdrantServiceAccount({ apiToken: 'any', domain: 'https://qdrant.example.com' }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) + + it('refuses a URL carrying credentials or a query before any request', async () => { + reply(jsonResponse({ result: { collections: [] } })) + + await expect( + validateQdrantServiceAccount({ + apiToken: 'key', + domain: 'https://user:pw@qdrant.example.com', + }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(secureFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index 12555723594..4df3d6b5717 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -5,6 +5,7 @@ import { validateOktaDomain, validateSupabaseProjectId, } from '@/lib/core/security/input-validation' +import { CLIENT_CREDENTIALS_AUTH_METHOD } from '@/lib/credentials/token-service-accounts/descriptors' import { fetchProvider, TokenServiceAccountValidationError, @@ -19,9 +20,12 @@ import { readJsonBody, tokenCheckValidator, } from '@/lib/credentials/token-service-accounts/validators/http' +import { githubTokenExpiration, gitlabTokenExpiration } from '@/lib/credentials/vendor-token-expiry' import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/lib/internal/jupyter/protocol' +import { getDashboardHost } from '@/tools/amplitude/utils' import { ashbyAuthHeaders } from '@/tools/ashby/utils' import { BITBUCKET_API_BASE, bitbucketHeaders } from '@/tools/bitbucket/utils' +import { CBINSIGHTS_API_BASE } from '@/tools/cbinsights/utils' import { checkrHeaders } from '@/tools/checkr/utils' import { convexApiUrl, convexAuthHeaders } from '@/tools/convex/utils' import { buildDynatraceUrl, dynatraceHeaders } from '@/tools/dynatrace/utils' @@ -29,7 +33,11 @@ import { normalizeEmailBisonBaseUrl } from '@/tools/emailbison/utils' import { normalizeGitLabHost } from '@/tools/gitlab/utils' import { gongAuthorization } from '@/tools/gong/utils' import { GRANOLA_API_BASE, granolaHeaders } from '@/tools/granola/utils' +import { DEFAULT_HEX_HOST, hexApiBase, normalizeHexHost } from '@/tools/hex/utils' import { buildJotformHeaders, buildJotformUrl, JOTFORM_REGION_HOSTS } from '@/tools/jotform/utils' +import { buildKalshiAuthHeaders, buildKalshiUrl } from '@/tools/kalshi/types' +import { LANGSMITH_REGIONS } from '@/tools/langsmith/utils' +import { LAUNCHDARKLY_INSTANCES } from '@/tools/launchdarkly/utils' import { getLogfireBaseUrl, LOGFIRE_READ_TOKEN_INFO_PATH, @@ -37,7 +45,7 @@ import { } from '@/tools/logfire/utils' import type { MailgunRegion } from '@/tools/mailgun/types' import { getMailgunApiBaseUrl } from '@/tools/mailgun/utils' -import { getNerdGraphEndpoint, newRelicHeaders } from '@/tools/new_relic/utils' +import { getNerdGraphEndpoint, NEW_RELIC_REGIONS, newRelicHeaders } from '@/tools/new_relic/utils' import { oktaHeaders } from '@/tools/okta/utils' import { planeApiUrl, planeHeaders } from '@/tools/plane/utils' import { @@ -45,7 +53,13 @@ import { PLANETSCALE_API_ORIGIN, planetScaleHeaders, } from '@/tools/planetscale/utils' -import { createBasicAuthHeader } from '@/tools/servicenow/utils' +import { RAILWAY_GRAPHQL_URL, railwayHeaders } from '@/tools/railway/utils' +import { SENDGRID_REGIONS } from '@/tools/sendgrid/utils' +import { SENTRY_REGIONS } from '@/tools/sentry/utils' +import { SERVICENOW_BEARER_AUTH_STYLE, serviceNowAuthorization } from '@/tools/servicenow/utils' +import type { CredentialRegions } from '@/tools/shared/credential-regions' +import { buildSplunkHeaders, buildSplunkUrl } from '@/tools/splunk/utils' +import { SQUARE_ENVIRONMENTS } from '@/tools/square/types' import { supabaseBaseUrl } from '@/tools/supabase/utils' import { getThriveBaseUrl, getThriveHeaders, THRIVE_HOSTS } from '@/tools/thrive/utils' import { isTwilioApiKeyPair, twilioAuthorization } from '@/tools/twilio/utils' @@ -62,6 +76,18 @@ import { WHATSAPP_GRAPH_VERSION } from '@/tools/whatsapp/utils' /** Regions for vendors with one US and one EU API host. */ const US_OR_EU: ReadonlySet = new Set(['us', 'eu']) +/** + * The connection fields of a credential whose domain is one of the vendor's fixed regions: only a + * listed region is accepted, and the default one is the host the vendor always used. + */ +function vendorRegion(regions: CredentialRegions) { + return { + domain: allowedDomain(regions), + defaultDomain: regions.defaultRegion, + domainIsVendorRegion: true, + } +} + /** Affinity's whoami endpoint answers 200 for a valid key and 401 otherwise. */ export const validateAffinityServiceAccount = tokenCheckValidator({ serviceLabel: 'Affinity', @@ -149,6 +175,43 @@ export const validateAlgoliaServiceAccount = tokenCheckValidator({ }, }) +/** Printable ASCII without a colon: what an Amplitude API key can be as a Basic username. */ +const AMPLITUDE_API_KEY = /^[\x21-\x39\x3b-\x7e]{1,256}$/ +/** Printable ASCII, so the Basic credential encodes exactly as the tools' `btoa` does. */ +const AMPLITUDE_SECRET_KEY = /^[\x21-\x7e]{1,256}$/ + +/** + * Amplitude's Dashboard REST API lists a project's visible events for the API key and secret key + * sent as HTTP Basic credentials, on the region's host the tools build with `getDashboardHost`. + * Amplitude answers 403 when either key is invalid (401 is a malformed header), so the check + * proves the pair and its region together; it is a read with no billing cost. + * https://amplitude.com/docs/apis/analytics/dashboard-rest + */ +export const validateAmplitudeServiceAccount = tokenCheckValidator({ + serviceLabel: 'Amplitude', + domain: allowedDomain(US_OR_EU), + workspaceId: ({ providerWorkspaceId }) => { + const apiKey = (providerWorkspaceId ?? '').trim() + if (!AMPLITUDE_API_KEY.test(apiKey)) throw new Error('invalid API key') + return apiKey + }, + request: ({ apiToken, providerWorkspaceId = '', domain }) => { + if (!AMPLITUDE_SECRET_KEY.test(apiToken)) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'verify', + reason: 'invalid Amplitude secret key', + }) + } + return { + url: `${getDashboardHost(domain)}/api/2/events/list`, + headers: { + Authorization: `Basic ${Buffer.from(`${providerWorkspaceId}:${apiToken}`).toString('base64')}`, + Accept: 'application/json', + }, + } + }, +}) + /** Apify's current-user endpoint answers 200 for a valid token and 401 otherwise. */ export const validateApifyServiceAccount = tokenCheckValidator({ serviceLabel: 'Apify', @@ -295,6 +358,41 @@ export const validateBufferServiceAccount = tokenCheckValidator({ }, }) +/** A CB Insights client ID: printable ASCII, sent only in the authorize request body. */ +const CBINSIGHTS_CLIENT_ID = /^[\x21-\x7e]{1,256}$/ + +/** + * Exchanging the client ID and secret for an access token at `POST /v2/authorize`, the call the + * tools make before their first request, proves the pair; a success without a token is not + * proof. The token is discarded: the tools mint their own on each run. + * https://api-docs.cbinsights.com/portal/docs/CBI-API/Authentication + */ +export const validateCbInsightsServiceAccount = tokenCheckValidator({ + serviceLabel: 'CB Insights', + workspaceId: ({ providerWorkspaceId }) => { + const clientId = (providerWorkspaceId ?? '').trim() + if (!CBINSIGHTS_CLIENT_ID.test(clientId)) throw new Error('invalid client ID') + return clientId + }, + errorBodyEchoesToken: true, + request: ({ apiToken, providerWorkspaceId = '' }) => ({ + url: `${CBINSIGHTS_API_BASE}/v2/authorize`, + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ clientId: providerWorkspaceId, clientSecret: apiToken }), + }), + acceptsBody: (body) => { + const token = toRecord(body).token + if (typeof token !== 'string' || !token) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'verify', + reason: 'CB Insights authorization returned no token', + }) + } + return true + }, +}) + /** * Calendly's current-user endpoint answers 200 for a valid token and 401 * otherwise; a valid token without the `users:read` scope gets a JSON 403. @@ -635,6 +733,8 @@ export const validateGitHubServiceAccount = tokenCheckValidator({ } return { displayName: `${login.trim()} (GitHub)` } }, + expiresAt: ({ headers }) => + githubTokenExpiration(headers.get('github-authentication-token-expiration')), }) /** @@ -708,6 +808,7 @@ export const validateGitLabServiceAccount = tokenCheckValidator({ const name = typeof token.name === 'string' ? token.name.trim() : '' return usable && name ? { displayName: name } : usable }, + expiresAt: ({ body }) => gitlabTokenExpiration(toRecord(body).expires_at), }) /** @@ -797,11 +898,16 @@ export const validateGranolaServiceAccount = tokenCheckValidator({ }), }) -/** Hex's list-projects endpoint (the one the Hex tools call) answers 200 for a valid token and 401 otherwise. */ +/** + * Hex's list-projects endpoint (the one the Hex tools call) answers 200 for a valid token and 401 + * otherwise, on the hex.tech host of the token's workspace. + */ export const validateHexServiceAccount = tokenCheckValidator({ serviceLabel: 'Hex', - request: ({ apiToken }) => ({ - url: 'https://app.hex.tech/api/v1/projects?limit=1', + domain: ({ domain }) => normalizeHexHost(domain), + defaultDomain: DEFAULT_HEX_HOST, + request: ({ apiToken, domain }) => ({ + url: `${hexApiBase(domain)}/projects?limit=1`, headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, }), }) @@ -914,20 +1020,104 @@ export const validateJupyterServiceAccount = tokenCheckValidator({ }, }) -/** LangSmith's workspace list answers 200 for a valid API key and 401/403 otherwise. */ +/** + * Listing collections (`GET /collections`) is a read every Qdrant key may run (a collection-scoped + * key sees a filtered list), sent with the `api-key` header the tools send. The instance URL is + * one the user chose, so the check runs through the guarded egress, and an answer without a + * collection list (a proxy or login page) is not taken as proof. A self-hosted instance with no + * API key configured accepts any key; the credential still binds the instance URL. + * https://api.qdrant.tech/api-reference/collections/get-collections + */ +export const validateQdrantServiceAccount = tokenCheckValidator({ + serviceLabel: 'Qdrant', + domain: ({ domain }) => normalizeHttpBaseUrl(domain), + egress: 'configuredEndpoint', + request: ({ apiToken, domain = '' }) => ({ + url: `${domain}/collections`, + headers: { 'api-key': apiToken, Accept: 'application/json' }, + }), + acceptsBody: (body) => { + if (!Array.isArray(toRecord(toRecord(body).result).collections)) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'verify', + reason: 'Qdrant returned no collection list', + }) + } + return true + }, +}) + +/** The token types a Railway credential names, each read by the query its scope allows. */ +const RAILWAY_TOKEN_TYPES = new Set(['account', 'workspace', 'project']) +/** A Railway workspace ID, sent only as a GraphQL variable. */ +const RAILWAY_WORKSPACE_ID = /^[A-Za-z0-9-]{1,64}$/ + +/** + * Each Railway token type has its own documented read, because `me` answers only an account + * token: an account token reads `me`, a workspace token reads its workspace by ID (the only + * workspace it can see), and a project token reads `projectToken` with the Project-Access-Token + * header the tools send for it. Railway reports a refused token as a GraphQL error, so only a + * body carrying the queried object proves it. + * https://docs.railway.com/integrations/api + */ +export const validateRailwayServiceAccount = tokenCheckValidator({ + serviceLabel: 'Railway', + domain: allowedDomain(RAILWAY_TOKEN_TYPES), + request: ({ apiToken, domain, providerWorkspaceId = '' }) => { + if (domain === 'workspace' && !RAILWAY_WORKSPACE_ID.test(providerWorkspaceId)) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'verify', + reason: 'a workspace token needs its workspace ID', + }) + } + const query = + domain === 'project' + ? { query: 'query { projectToken { projectId environmentId } }' } + : domain === 'workspace' + ? { + query: + 'query Workspace($workspaceId: String!) { workspace(workspaceId: $workspaceId) { id } }', + variables: { workspaceId: providerWorkspaceId }, + } + : { query: 'query { me { id } }' } + return { + url: RAILWAY_GRAPHQL_URL, + method: 'POST', + headers: railwayHeaders(apiToken, domain === 'project' ? 'project' : 'account'), + body: JSON.stringify(query), + } + }, + acceptsBody: (body) => { + const record = toRecord(body) + if (toArray(record.errors).length > 0) return false + const [queried] = Object.values(toRecord(record.data)) + return Object.keys(toRecord(queried)).length > 0 + }, +}) + +/** + * LangSmith's workspace list answers 200 for a valid API key and 401/403 otherwise, in the region + * the key's organization lives in; a key from another region is not recognized. + */ export const validateLangsmithServiceAccount = tokenCheckValidator({ serviceLabel: 'LangSmith', - request: ({ apiToken }) => ({ - url: 'https://api.smith.langchain.com/api/v1/workspaces', + ...vendorRegion(LANGSMITH_REGIONS), + request: ({ apiToken, domain }) => ({ + url: `${LANGSMITH_REGIONS.apiBase(domain)}/workspaces`, headers: { 'X-Api-Key': apiToken, Accept: 'application/json' }, }), }) -/** LaunchDarkly's caller-identity endpoint describes the calling token, so it answers 200 for any valid token and 401 otherwise. */ +/** + * LaunchDarkly's caller-identity endpoint describes the calling token, so it answers 200 for any + * valid token and 401 otherwise, on the instance (commercial, EU, or federal) the token was + * created in. + */ export const validateLaunchdarklyServiceAccount = tokenCheckValidator({ serviceLabel: 'LaunchDarkly', - request: ({ apiToken }) => ({ - url: 'https://app.launchdarkly.com/api/v2/caller-identity', + ...vendorRegion(LAUNCHDARKLY_INSTANCES), + request: ({ apiToken, domain }) => ({ + url: `${LAUNCHDARKLY_INSTANCES.apiBase(domain)}/caller-identity`, headers: { Authorization: apiToken, Accept: 'application/json' }, }), }) @@ -1038,6 +1228,86 @@ export const validateMailchimpServiceAccount = tokenCheckValidator({ }, }) +/** A Kalshi API key ID travels as a header value, so only printable ASCII without spaces. */ +const KALSHI_KEY_ID = /^[\x21-\x7e]{1,128}$/ +const KALSHI_BALANCE_PATH = '/portfolio/balance' + +/** + * Reading the account balance is a free, read-only request signed the way the tools sign every + * authenticated call (`buildKalshiAuthHeaders`), so it succeeds only when the signature verifies + * against the key ID's registered public key; any refusal rejects the key. A private key that cannot sign + * (not PEM, or neither RSA nor Ed25519) is refused before any request. + * https://docs.kalshi.com/getting_started/api_keys + */ +export const validateKalshiServiceAccount = tokenCheckValidator({ + serviceLabel: 'Kalshi', + workspaceId: ({ providerWorkspaceId }) => { + const keyId = (providerWorkspaceId ?? '').trim() + if (!KALSHI_KEY_ID.test(keyId)) throw new Error('invalid API key ID') + return keyId + }, + request: ({ apiToken, providerWorkspaceId = '' }) => { + let headers: Record + try { + headers = buildKalshiAuthHeaders( + providerWorkspaceId, + apiToken, + 'GET', + new URL(buildKalshiUrl(KALSHI_BALANCE_PATH)).pathname + ) + } catch { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'verify', + reason: 'Kalshi private key cannot sign', + }) + } + return { + url: buildKalshiUrl(KALSHI_BALANCE_PATH), + headers: { ...headers, Accept: 'application/json' }, + } + }, +}) + +/** + * Splunk's `authentication/current-context` returns the user a request authenticates as and + * needs no capability, so any valid authentication token, or username and password sent as HTTP + * Basic the way the tools send them, reads it; refused credentials answer 401. A username is the + * Basic username, so one with a colon is refused unsent. The management URL is one the user + * chose, so the check runs through the guarded egress, and an answer without a user context (a + * proxy or login page) is not taken as proof. + * https://help.splunk.com/en/splunk-enterprise/leverage-rest-apis/rest-api-reference/10.6/access-endpoints/access-endpoint-descriptions + */ +export const validateSplunkServiceAccount = tokenCheckValidator({ + serviceLabel: 'Splunk', + domain: ({ domain }) => normalizeHttpBaseUrl(domain), + workspaceId: ({ providerWorkspaceId }) => { + const username = (providerWorkspaceId ?? '').trim() + if (!username) return undefined + if (!/^[^:\x00-\x1f\x7f]+$/.test(username)) throw new Error('invalid username') + return username + }, + egress: 'configuredEndpoint', + request: ({ apiToken, domain = '', providerWorkspaceId }) => ({ + url: buildSplunkUrl({ baseUrl: domain }, '/authentication/current-context'), + headers: buildSplunkHeaders( + providerWorkspaceId + ? { baseUrl: domain, username: providerWorkspaceId, password: apiToken } + : { baseUrl: domain, authToken: apiToken } + ), + }), + acceptsBody: (body) => { + const [entry] = toArray(toRecord(body).entry) + const username = toRecord(toRecord(entry).content).username + if (typeof username !== 'string' || !username.trim()) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'verify', + reason: 'Splunk returned no user context', + }) + } + return true + }, +}) + /** * Listing one domain on the region's host answers 200 for a valid account API key and 401 * ("Invalid private key") otherwise; a key from the other region is refused the same way, so the @@ -1079,16 +1349,16 @@ const NERDGRAPH_RETRYABLE_ERROR_CLASSES: ReadonlySet = new Set([ /** * A New Relic User API key is bound to its data center, so it is checked against the - * NerdGraph host of the region typed with it (`us` or `eu`, stored lower-cased because - * the tools send anything but `eu` to the US host). An unknown key, or one from the other + * NerdGraph host of the region typed with it (`us`, `eu`, or `jp`, stored lower-cased because + * the tools send anything else to the US host). An unknown key, or one from the other * region, gets 401, or a 200 whose `errors` carry `BAD_API_KEY`, so only a resolved user * proves the key. Only an error in a server class means NerdGraph failed to answer. */ export const validateNewRelicServiceAccount = tokenCheckValidator({ serviceLabel: 'New Relic', - domain: allowedDomain(US_OR_EU), + domain: allowedDomain(NEW_RELIC_REGIONS), request: ({ apiToken, domain }) => ({ - url: getNerdGraphEndpoint(domain as 'us' | 'eu'), + url: getNerdGraphEndpoint(domain), method: 'POST', headers: { ...newRelicHeaders(apiToken), Accept: 'application/json' }, body: JSON.stringify({ query: '{ actor { user { id } } }' }), @@ -1344,11 +1614,16 @@ export const validateRootlyServiceAccount = tokenCheckValidator({ }), }) -/** SendGrid's scopes endpoint lists the scopes of the key that calls it, so any valid key gets 200 and a bad one 401. */ +/** + * SendGrid's scopes endpoint lists the scopes of the key that calls it, so any valid key gets 200 + * and a bad one 401. SendGrid serves it on both the global host and the EU host of regional + * subusers: https://www.twilio.com/docs/sendgrid/api-reference/api-key-permissions/retrieve-a-list-of-scopes-for-which-this-user-has-access + */ export const validateSendGridServiceAccount = tokenCheckValidator({ serviceLabel: 'SendGrid', - request: ({ apiToken }) => ({ - url: 'https://api.sendgrid.com/v3/scopes', + ...vendorRegion(SENDGRID_REGIONS), + request: ({ apiToken, domain }) => ({ + url: `${SENDGRID_REGIONS.apiBase(domain)}/scopes`, headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, }), }) @@ -1356,13 +1631,15 @@ export const validateSendGridServiceAccount = tokenCheckValidator({ /** * Listing the token's Sentry organizations answers 200 for a valid token and 401 for a bad one; * a token without `org:read` (an organization token, or a narrowly scoped one) gets Sentry's JSON - * 403. + * 403. The list is per region, so it is read on the host of the region the token's organization + * stores its data in: https://docs.sentry.io/api/users/list-your-organizations/ */ export const validateSentryServiceAccount = tokenCheckValidator({ serviceLabel: 'Sentry', forbiddenMeansScoped: true, - request: ({ apiToken }) => ({ - url: 'https://sentry.io/api/0/organizations/', + ...vendorRegion(SENTRY_REGIONS), + request: ({ apiToken, domain }) => ({ + url: `${SENTRY_REGIONS.apiBase(domain)}/organizations/`, headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, }), }) @@ -1375,7 +1652,7 @@ export const validateSentryServiceAccount = tokenCheckValidator({ * `configuredEndpoint` egress check refuses private and loopback addresses, and the saved password * only ever goes to the host stored with it. */ -function normalizeServiceNowInstanceUrl(raw: string | undefined): string { +export function normalizeServiceNowInstanceUrl(raw: string | undefined): string { const typed = refuseUnsafeTypedUrl(raw) const url = new URL(/^https?:\/\//i.test(typed) ? typed : `https://${typed}`) if (url.protocol !== 'https:') throw new Error('https only') @@ -1388,21 +1665,31 @@ function normalizeServiceNowInstanceUrl(raw: string | undefined): string { * credentials with 401 and a request its ACLs refuse with a JSON 403, which still proves the * login. The username is the Basic username, so one with a colon is refused unsent. Only a * Table API `result` list proves the login: a hibernating instance answers 200 with a page. + * A token an OAuth client minted is proven with the same read as a bearer token, the way the + * tools send it, and has no username. */ export const validateServiceNowServiceAccount = tokenCheckValidator({ serviceLabel: 'ServiceNow', domain: ({ domain }) => normalizeServiceNowInstanceUrl(domain), - workspaceId: ({ providerWorkspaceId }) => { + workspaceId: ({ providerWorkspaceId, authMethod }) => { + if (authMethod === CLIENT_CREDENTIALS_AUTH_METHOD) return undefined const username = (providerWorkspaceId ?? '').trim() if (!/^[^:\x00-\x1f\x7f]+$/.test(username)) throw new Error('invalid username') return username }, egress: 'configuredEndpoint', forbiddenMeansScoped: true, - request: ({ apiToken, domain, providerWorkspaceId = '' }) => ({ + request: ({ apiToken, domain, providerWorkspaceId = '', authMethod }) => ({ url: `${domain}/api/now/table/sys_user?sysparm_limit=1&sysparm_fields=sys_id`, headers: { - Authorization: createBasicAuthHeader(providerWorkspaceId, apiToken), + Authorization: serviceNowAuthorization({ + username: providerWorkspaceId, + password: apiToken, + accessToken: apiToken, + ...(authMethod === CLIENT_CREDENTIALS_AUTH_METHOD + ? { authStyle: SERVICENOW_BEARER_AUTH_STYLE } + : {}), + }), Accept: 'application/json', }, }), @@ -1454,13 +1741,15 @@ export const validateSportmonksServiceAccount = tokenCheckValidator({ }) /** - * Square's list-locations read answers 200 for a valid production access token and 401 for a bad - * one; a token without the merchant-profile scope gets 403. + * Square's list-locations read answers 200 for a valid access token and 401 for a bad one, in the + * environment (production or sandbox) that issued it; a token without the merchant-profile scope + * gets 403. */ export const validateSquareServiceAccount = tokenCheckValidator({ serviceLabel: 'Square', - request: ({ apiToken }) => ({ - url: 'https://connect.squareup.com/v2/locations', + ...vendorRegion(SQUARE_ENVIRONMENTS), + request: ({ apiToken, domain }) => ({ + url: `${SQUARE_ENVIRONMENTS.apiBase(domain)}/v2/locations`, headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, }), forbiddenMeansScoped: true, diff --git a/apps/sim/lib/credentials/vendor-token-expiry.ts b/apps/sim/lib/credentials/vendor-token-expiry.ts new file mode 100644 index 00000000000..6b72a51567a --- /dev/null +++ b/apps/sim/lib/credentials/vendor-token-expiry.ts @@ -0,0 +1,72 @@ +/** + * Readers for the expiry a vendor states for a token it just accepted. Each returns `undefined` + * for a missing value, an unreadable one, or a calendar date that does not exist, so a caller never + * records a wrong moment. + */ + +/** + * The UTC moment for a calendar date and time, or `undefined` for one that does not exist (a + * 30 February, hour 24): `Date` would roll it over into a different, wrong moment. + */ +function calendarMomentUtc( + [year, month, day, hour = 0, minute = 0, second = 0]: readonly number[], + offsetMinutes = 0 +): Date | undefined { + const local = new Date(Date.UTC(year, month - 1, day, hour, minute, second)) + if ( + local.getUTCFullYear() !== year || + local.getUTCMonth() !== month - 1 || + local.getUTCDate() !== day || + local.getUTCHours() !== hour || + local.getUTCMinutes() !== minute || + local.getUTCSeconds() !== second + ) { + return undefined + } + return new Date(local.getTime() - offsetMinutes * 60_000) +} + +/** + * `2026-12-01 12:30:00 +0200` or `2026-12-01 10:30:00 UTC`. GitHub documents the header but not + * its value format, so this is parsed strictly by design: the two forms accepted are the ones in + * the API responses GitHub's own client library records (octokit/octokit.rb spec cassettes), and + * any other form (including an offset past 23 hours or 59 minutes) is left unread. + */ +const GITHUB_TOKEN_EXPIRATION = + /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-](?:[01]\d|2[0-3])[0-5]\d|UTC)$/ + +/** + * Reads GitHub's `GitHub-Authentication-Token-Expiration` response header, which GitHub sends on + * API calls made with a personal access token that has an expiration date, and omits for one that + * never expires. https://github.blog/changelog/2021-07-26-expiration-options-for-personal-access-tokens/ + */ +export function githubTokenExpiration(header: string | null): Date | undefined { + const match = header ? GITHUB_TOKEN_EXPIRATION.exec(header.trim()) : null + if (!match) return undefined + const offset = match[7] + const offsetMinutes = + offset === 'UTC' + ? 0 + : (offset.startsWith('-') ? -1 : 1) * + (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(3, 5))) + return calendarMomentUtc(match.slice(1, 7).map(Number), offsetMinutes) +} + +/** + * GitLab's `expires_at` date for a token: it stops working at midnight UTC on that date. + * https://docs.gitlab.com/user/profile/personal_access_tokens/ + */ +export function gitlabTokenExpiration(expiresAt: unknown): Date | undefined { + const match = typeof expiresAt === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(expiresAt) : null + return match ? calendarMomentUtc(match.slice(1, 4).map(Number)) : undefined +} + +/** + * A stated expiry for a token the vendor just accepted, when it is still ahead. The acceptance + * proves the token works now, so a moment already past contradicts the request (a misread value or + * a skewed clock) and is dropped rather than recorded as fact; an imminent one is kept. + */ +export function credibleTokenExpiry(expiresAt: Date | undefined): Date | undefined { + if (!expiresAt || Number.isNaN(expiresAt.getTime())) return undefined + return expiresAt.getTime() > Date.now() ? expiresAt : undefined +} diff --git a/apps/sim/lib/credentials/workload-identity/audiences.test.ts b/apps/sim/lib/credentials/workload-identity/audiences.test.ts new file mode 100644 index 00000000000..e8749be42be --- /dev/null +++ b/apps/sim/lib/credentials/workload-identity/audiences.test.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { workloadIdentityAudienceSchema } from '@/lib/credentials/workload-identity/audiences' + +describe('workloadIdentityAudienceSchema', () => { + it.each([ + ['databricks', 'sts.amazonaws.com'], + ['aws', 'api://AzureADTokenExchange'], + ['microsoft', 'snowflakecomputing.com'], + ['snowflake', 'sim-databricks'], + ['google', 'sts.amazonaws.com'], + ] as const)('refuses another cloud’s default audience for %s', (provider, audience) => { + expect(workloadIdentityAudienceSchema(provider).safeParse(audience).success).toBe(false) + }) + + it('refuses a Google provider audience outside Google, and allows it for Google', () => { + const googleAudience = + '//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/p/providers/q' + expect(workloadIdentityAudienceSchema('aws').safeParse(googleAudience).success).toBe(false) + expect(workloadIdentityAudienceSchema('google').safeParse(googleAudience).success).toBe(true) + }) + + it.each([ + ['has space'], + [''], + ['tab\there'], + ['a'.repeat(256)], + ['ünicode'], + ["it's"], + ['a,b'], + ['back\\slash'], + ['$HOME'], + ])('refuses %j', (audience) => { + expect(workloadIdentityAudienceSchema('aws').safeParse(audience).success).toBe(false) + }) +}) diff --git a/apps/sim/lib/credentials/workload-identity/audiences.ts b/apps/sim/lib/credentials/workload-identity/audiences.ts index e8e1be09a51..350f82db45f 100644 --- a/apps/sim/lib/credentials/workload-identity/audiences.ts +++ b/apps/sim/lib/credentials/workload-identity/audiences.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors' import { AWS_SERVICE_ACCOUNT_PROVIDER_ID, @@ -24,7 +25,7 @@ export type WorkloadIdentityProvider = (typeof WORKLOAD_IDENTITY_PROVIDERS)[numb export const WORKLOAD_IDENTITY_PROVIDER_AUTH_TYPES = { aws: ['oidc_role', 'assume_role', 'access_keys'], databricks: ['oidc_federation', 'service_principal', 'personal_access_token'], - snowflake: ['workload_identity', 'programmatic_access_token'], + snowflake: ['workload_identity', 'key_pair', 'programmatic_access_token'], microsoft: ['federated', 'client_secret', 'certificate'], google: ['workload_identity', 'json_key'], } as const satisfies Record @@ -68,25 +69,29 @@ export const WORKLOAD_IDENTITY_KEYLESS_CONFIGS: Record< aws: { authType: 'oidc_role', requiredFields: ['roleArn'], - optionalFields: ['stsRegion', 'sessionDurationSeconds'], + optionalFields: ['stsRegion', 'sessionDurationSeconds', 'audience'], }, databricks: { authType: 'oidc_federation', requiredFields: ['host', 'clientId'], - optionalFields: [], + optionalFields: ['audience'], + }, + snowflake: { + authType: 'workload_identity', + requiredFields: ['host'], + optionalFields: ['audience'], }, - snowflake: { authType: 'workload_identity', requiredFields: ['host'], optionalFields: [] }, microsoft: { authType: 'federated', requiredFields: ['tenantId', 'clientId'], - optionalFields: ['environmentUrl'], + optionalFields: ['environmentUrl', 'cloud', 'audience'], }, google: { authType: 'workload_identity', requiredFields: ['projectNumber', 'poolId', 'providerId', 'serviceAccountEmail'], - optionalFields: [], + optionalFields: ['delegation', 'audience'], scope: - 'Keyless works only for Google Cloud services such as BigQuery and Vertex AI, not Google Workspace (Gmail, Drive, Sheets, Calendar, and the like), which need a JSON key with domain-wide delegation.', + 'Keyless works for Google Cloud services such as BigQuery and Vertex AI. For Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar, add `delegation` as {"user": "", "services": ["gmail", "google-drive", "google-docs", "google-sheets", "google-calendar"]} (the services you need) for domain-wide delegation: grant the subject roles/iam.serviceAccountTokenCreator instead of roles/iam.workloadIdentityUser, authorize the service account’s client ID for those services’ scopes in the Google Workspace Admin console, and workflows act only as that user. Other Google Workspace services need a JSON key.', }, } @@ -127,9 +132,10 @@ export function workloadIdentityProviderFor( type FixedAudienceWorkloadIdentityProvider = Exclude /** - * The `aud` claim each relying party expects. AWS STS requires its own client - * ID; Databricks federation policies list whatever audience the customer - * enters, so Sim publishes one fixed value for them to paste. Snowflake accepts + * The default `aud` claim for each relying party, used when a keyless credential + * sets no `audience` of its own (see `workloadIdentityAudienceSchema`). AWS STS + * conventionally uses its own client ID; Databricks federation policies list + * whatever audience the customer enters, so Sim publishes one default to paste. Snowflake accepts * `snowflakecomputing.com` without any `OIDC_AUDIENCE_LIST` on the user, so the * customer's `CREATE USER` names only the issuer and subject. Microsoft Entra * federated identity credentials take one audience, and Microsoft recommends @@ -143,9 +149,89 @@ export const WORKLOAD_IDENTITY_AUDIENCES: Record other !== provider).flatMap((other) => { + const audience = fixedWorkloadIdentityAudience(other) + return audience ? [audience] : [] + }) + ) + return z + .string() + .trim() + .min(1, 'Audience cannot be empty') + .max( + MAX_WORKLOAD_IDENTITY_AUDIENCE_LENGTH, + `Audience must be at most ${MAX_WORKLOAD_IDENTITY_AUDIENCE_LENGTH} characters` + ) + .regex( + AUDIENCE_PATTERN, + 'Audience can use only letters, digits, and the characters . _ ~ : / ? # @ & + = % -' + ) + .refine((audience) => !othersDefaults.has(audience), { + message: 'That audience belongs to another cloud. Use the default or your own value.', + }) + .refine((audience) => provider === 'google' || !isGoogleProviderAudience(audience), { + message: 'That audience belongs to Google Cloud. Use the default or your own value.', + }) +} + +const AUDIENCE_SCHEMAS = Object.fromEntries( + WORKLOAD_IDENTITY_PROVIDERS.map((provider) => [provider, buildAudienceSchema(provider)]) +) as Record> + +/** + * A keyless credential's own `aud` claim, so a customer can pin their trust + * configuration to an audience no other Sim credential is issued for (their + * Databricks account ID, say). Another relying party's default is refused: a + * credential for one cloud never carries a token another cloud would accept. + * Built once per provider, so the connect dialog can check each keystroke. + */ +export function workloadIdentityAudienceSchema(provider: WorkloadIdentityProvider) { + return AUDIENCE_SCHEMAS[provider] +} + +/** A Google workload identity provider's resource-name audience. */ +function isGoogleProviderAudience(audience: string): boolean { + return /^(https:)?\/\/iam\.googleapis\.com\//i.test(audience) +} + +/** + * The optional `audience` field of a stored keyless config. Bounded by length only, + * so a credential saved before a submit-time rule tightened still reads. + */ +export const storedWorkloadIdentityAudienceSchema = z + .string() + .min(1) + .max(MAX_WORKLOAD_IDENTITY_AUDIENCE_LENGTH) + .optional() + +/** + * The audience field to spread into a stored keyless config: empty when none was + * submitted or it is a default, so a default-audience credential keeps the exact + * stored shape earlier releases read. A reconnect that submits none therefore + * returns to the default. + */ +export function storedAudienceField( + submitted: string | undefined, + ...defaultAudiences: string[] +): { audience?: string } { + return submitted && !defaultAudiences.includes(submitted) ? { audience: submitted } : {} +} diff --git a/apps/sim/lib/desktop/index.test.ts b/apps/sim/lib/desktop/index.test.ts index 1a7436c7e83..d959b8b1c20 100644 --- a/apps/sim/lib/desktop/index.test.ts +++ b/apps/sim/lib/desktop/index.test.ts @@ -1,13 +1,4 @@ -import { createDeferred } from '@sim/testing/helpers/deferred' -import { - apiClientRequestMock, - apiClientRequestMockFns, -} from '@sim/testing/mocks/api-client-request.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/api/client/request', () => apiClientRequestMock) -const requestAvailability = apiClientRequestMockFns.mockRequestJson - import { getDesktopChatCapabilities, hasBrowserAgent, @@ -39,13 +30,12 @@ function installBridge(value: unknown): void { describe('desktop surface availability', () => { it.each([ - [true, true, true, true], - [true, true, false, false], - [true, false, true, false], - [false, true, true, false], + [true, true, true], + [true, false, false], + [false, true, false], ])( - 'advertises computer use only for a supported enabled device and server rollout (%s,%s,%s)', - async (supported, enabled, rollout, expected) => { + 'advertises computer use only for a supported, opted-in device (%s,%s)', + async (supported, enabled, expected) => { installBridge({ computerUse: { getStatus: vi.fn(async () => ({ @@ -61,62 +51,23 @@ describe('desktop surface availability', () => { browserEnabled: false, terminalEnabled: false, }) - requestAvailability.mockResolvedValueOnce({ enabled: rollout }) - const result = await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' }) + const result = await getDesktopChatCapabilities('chat-1') expect(result.desktopCapabilities?.computerUse ?? false).toBe(expected) } ) - it('leaves ordinary chat capabilities independent of computer use while rollout is off', async () => { - const getStatus = vi.fn(() => new Promise(() => {})) - installBridge({ - computerUse: { getStatus }, - localFiles: vi.fn(), - terminal: { getTabs: vi.fn(async () => ({ tabs: [] })) }, - browserAgent: { getKnownSessions: vi.fn(async () => ({ sessions: [] })) }, - }) - requestAvailability.mockResolvedValue({ enabled: false }) - const result = await getDesktopChatCapabilities('chat-1', null) - expect(requestAvailability).not.toHaveBeenCalled() - expect(getStatus).not.toHaveBeenCalled() - expect(result.desktopCapabilities).toMatchObject({ - localFiles: true, - browser: true, - terminal: true, - }) - }) - it('does not touch native computer use before the current rollout is confirmed', async () => { - const rollout = createDeferred<{ enabled: boolean }>() - const getStatus = vi.fn(async () => ({ supported: true, enabled: true })) - installBridge({ computerUse: { getStatus } }) - setDesktopPreferencesSnapshot({ - ...ENABLED_PREFERENCES, - browserEnabled: false, - terminalEnabled: false, + it('fails closed when the native status cannot be read', async () => { + installBridge({ + computerUse: { getStatus: vi.fn(async () => Promise.reject(new Error('gone'))) }, }) - requestAvailability.mockReturnValueOnce(rollout.promise) - const result = getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' }) - await vi.waitFor(() => expect(requestAvailability).toHaveBeenCalledOnce()) - expect(getStatus).not.toHaveBeenCalled() - rollout.resolve({ enabled: false }) - expect((await result).desktopCapabilities?.computerUse).toBeUndefined() - expect(getStatus).not.toHaveBeenCalled() - }) - - it('fails closed when the computer rollout cannot be resolved', async () => { - const getStatus = vi.fn(async () => ({ supported: true, enabled: true })) - installBridge({ computerUse: { getStatus } }) setDesktopPreferencesSnapshot({ ...ENABLED_PREFERENCES, browserEnabled: false, terminalEnabled: false, }) - requestAvailability.mockRejectedValueOnce(new Error('offline')) expect( - (await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' })).desktopCapabilities - ?.computerUse + (await getDesktopChatCapabilities('chat-1')).desktopCapabilities?.computerUse ).toBeUndefined() - expect(getStatus).not.toHaveBeenCalled() }) beforeEach(() => { @@ -144,12 +95,12 @@ describe('desktop surface availability', () => { browserEnabled: false, terminalEnabled: false, }) - expect(await getDesktopChatCapabilities('org-chat', null)).toMatchObject({ + expect(await getDesktopChatCapabilities('org-chat')).toMatchObject({ desktopCapabilities: { localFiles: true }, }) installBridge({}) expect( - (await getDesktopChatCapabilities('org-chat', null)).desktopCapabilities?.localFiles + (await getDesktopChatCapabilities('org-chat')).desktopCapabilities?.localFiles ).toBeUndefined() }) @@ -161,9 +112,7 @@ describe('desktop surface availability', () => { }) const offerFrom = async (bridge: Record) => { installBridge({ localFiles: vi.fn(), ...bridge }) - const { desktopCapabilities } = await getDesktopChatCapabilities('chat-1', { - workspaceId: 'ws-1', - }) + const { desktopCapabilities } = await getDesktopChatCapabilities('chat-1') return { deviceId: desktopCapabilities?.deviceId, executor: desktopCapabilities?.executor } } const unset = { deviceId: undefined, executor: undefined } @@ -211,7 +160,7 @@ describe('desktop surface availability', () => { browserEnabled: false, }) - const capabilities = await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' }) + const capabilities = await getDesktopChatCapabilities('chat-1') expect(capabilities.desktopCapabilities?.terminals).toEqual([ { diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 44ce2c97b81..307c72b9a75 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -24,11 +24,6 @@ import type { BrowserKnownSession } from '@sim/browser-protocol' import type { DesktopPreferences, SimDesktopApi } from '@sim/desktop-bridge' import { truncate } from '@sim/utils/string' -import { requestJson } from '@/lib/api/client/request' -import { - type ComputerUseOwner, - computerUseAvailabilityContract, -} from '@/lib/api/contracts/computer-use' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, @@ -179,8 +174,7 @@ export interface DesktopChatCapabilities { * in a plain web browser the model never sees the features. */ export async function getDesktopChatCapabilities( - scopeId: string, - computerUseOwner: ComputerUseOwner | null + scopeId: string ): Promise { const bridge = getDesktopBridge() // Never advertise a surface the user switched off, even on the first @@ -190,19 +184,13 @@ export async function getDesktopChatCapabilities( const localFilesystem = hasLocalFilesystem() const browser = isBrowserAgentEnabled() const terminal = isTerminalEnabled() - const computerUse = - computerUseOwner && bridge?.computerUse - ? await requestJson(computerUseAvailabilityContract, { - query: computerUseOwner, - signal: AbortSignal.timeout(5000), - }) - .then(async (rollout) => { - if (!rollout.enabled) return false - const status = await bridge.computerUse?.getStatus() - return status?.supported === true && status.enabled - }) - .catch(() => false) - : false + // Device state only; the server decides whether the chat's permission group allows it. + const computerUse = bridge?.computerUse + ? await bridge.computerUse + .getStatus() + .then((status) => status.supported && status.enabled) + .catch(() => false) + : false // Sent every request so the agent knows what is already running without // spending a tool call to ask — and, more importantly, so it notices a // terminal that is occupied instead of launching a second copy into it. diff --git a/apps/sim/lib/integrations/credential-display.test.ts b/apps/sim/lib/integrations/credential-display.test.ts index 73ea26193ba..d437f10ecee 100644 --- a/apps/sim/lib/integrations/credential-display.test.ts +++ b/apps/sim/lib/integrations/credential-display.test.ts @@ -47,6 +47,7 @@ const EXPECTED_COVERAGE: Record = { 'github-service-account': ['github'], 'google-service-account': [ 'gmail', + 'google-ads', 'google-bigquery', 'google-calendar', 'google-contacts', diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index b66173fab2e..93ff38e9f9e 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -310,6 +310,7 @@ export const blockTypeToIconMap: Record = { algolia: AlgoliaIcon, algolia_v2: AlgoliaIcon, amplitude: AmplitudeIcon, + amplitude_v2: AmplitudeIcon, apify: ApifyIcon, apify_v2: ApifyIcon, apollo: ApolloIcon, @@ -324,6 +325,7 @@ export const blockTypeToIconMap: Record = { athena_v2: AthenaIcon, attio: AttioIcon, azure_data_explorer: AzureDataExplorerIcon, + azure_data_explorer_v2: AzureDataExplorerIcon, azure_devops: AzureIcon, azure_devops_v2: AzureIcon, bitbucket: BitbucketIcon, @@ -342,6 +344,7 @@ export const blockTypeToIconMap: Record = { calendly: CalendlyIcon, calendly_v2: CalendlyIcon, cbinsights: CbInsightsIcon, + cbinsights_v2: CbInsightsIcon, checkr: CheckrIcon, checkr_v2: CheckrIcon, circleback: CirclebackIcon, @@ -508,7 +511,7 @@ export const blockTypeToIconMap: Record = { jupyter: JupyterIcon, jupyter_v3: JupyterIcon, kalshi: KalshiIcon, - kalshi_v2: KalshiIcon, + kalshi_v3: KalshiIcon, ketch: KetchIcon, knowledge: PackageSearchIcon, lambda: LambdaIcon, @@ -563,7 +566,9 @@ export const blockTypeToIconMap: Record = { monday: MondayIcon, mongodb: MongoDBIcon, mssql: MicrosoftSqlIcon, + mssql_v2: MicrosoftSqlIcon, mysql: MySQLIcon, + mysql_v2: MySQLIcon, neo4j: Neo4jIcon, netsuite: NetSuiteIcon, neverbounce: NeverBounceIcon, @@ -597,6 +602,7 @@ export const blockTypeToIconMap: Record = { planetscale_v2: PlanetScaleIcon, polymarket: PolymarketIcon, postgresql: PostgresIcon, + postgresql_v2: PostgresIcon, posthog: PosthogIcon, powerbi: PowerBIIcon, profound: ProfoundIcon, @@ -605,6 +611,7 @@ export const blockTypeToIconMap: Record = { pulse: PulseIcon, pulse_v3: PulseIcon, qdrant: QdrantIcon, + qdrant_v2: QdrantIcon, quartr: QuartrIcon, quartr_v2: QuartrIcon, quickbooks: QuickBooksIcon, @@ -612,6 +619,7 @@ export const blockTypeToIconMap: Record = { quiver_v3: QuiverIcon, rabbitmq: RabbitmqIcon, railway: RailwayIcon, + railway_v2: RailwayIcon, ramp: RampIcon, rb2b: RB2BIcon, rb2b_v2: RB2BIcon, @@ -669,6 +677,7 @@ export const blockTypeToIconMap: Record = { smtp: SmtpIcon, snowflake: SnowflakeIcon, splunk: SplunkIcon, + splunk_v2: SplunkIcon, sportmonks: SportmonksIcon, sportmonks_v2: SportmonksIcon, sqs: SQSIcon, diff --git a/apps/sim/lib/internal/athena/operations.test.ts b/apps/sim/lib/internal/athena/operations.test.ts index 730080ed28c..0ad266c36ad 100644 --- a/apps/sim/lib/internal/athena/operations.test.ts +++ b/apps/sim/lib/internal/athena/operations.test.ts @@ -51,8 +51,8 @@ describe('Athena operations', () => { success: true, output: { columns: [ - { name: 'name', type: 'varchar' }, - { name: 'count', type: 'bigint' }, + expect.objectContaining({ name: 'name', type: 'varchar' }), + expect.objectContaining({ name: 'count', type: 'bigint' }), ], rows: [{ name: 'sim', count: '3' }], nextToken: 'next-page', @@ -90,4 +90,19 @@ describe('Athena operations', () => { }) expect(mocks.destroy).toHaveBeenCalledOnce() }) + + it('keeps the first row of a DDL result, which has no header row', async () => { + mocks.send.mockResolvedValue({ + ResultSet: { + ResultSetMetadata: { ColumnInfo: [{ Name: 'tab_name', Type: 'string' }] }, + Rows: [{ Data: [{ VarCharValue: 'orders' }] }, { Data: [{ VarCharValue: 'users' }] }], + }, + }) + + await expect( + executeAthenaGetQueryResults({ ...CONNECTION, queryExecutionId: 'show-tables-id' }) + ).resolves.toMatchObject({ + output: { rows: [{ tab_name: 'orders' }, { tab_name: 'users' }] }, + }) + }) }) diff --git a/apps/sim/lib/internal/athena/operations.ts b/apps/sim/lib/internal/athena/operations.ts index 91fe4046f81..11660e2ed79 100644 --- a/apps/sim/lib/internal/athena/operations.ts +++ b/apps/sim/lib/internal/athena/operations.ts @@ -3,6 +3,7 @@ import { BatchGetNamedQueryCommand, BatchGetPreparedStatementCommand, BatchGetQueryExecutionCommand, + type ColumnInfo, CreateNamedQueryCommand, CreatePreparedStatementCommand, type Database, @@ -27,6 +28,8 @@ import { ListWorkGroupsCommand, type NamedQuery, type PreparedStatement, + type QueryExecution, + type Row, StartQueryExecutionCommand, StopQueryExecutionCommand, type TableMetadata, @@ -131,6 +134,48 @@ function mapEngineVersion(engineVersion: EngineVersion | undefined) { } } +/** Query execution fields beyond the core status and statistics every caller reads. */ +function mapQueryExecutionDetails(execution: QueryExecution) { + const athenaError = execution.Status?.AthenaError + return { + servicePreProcessingTimeInMillis: + execution.Statistics?.ServicePreProcessingTimeInMillis ?? null, + serviceProcessingTimeInMillis: execution.Statistics?.ServiceProcessingTimeInMillis ?? null, + dataManifestLocation: execution.Statistics?.DataManifestLocation ?? null, + reusedPreviousResult: + execution.Statistics?.ResultReuseInformation?.ReusedPreviousResult ?? null, + athenaError: athenaError + ? { + errorCategory: athenaError.ErrorCategory ?? null, + errorType: athenaError.ErrorType ?? null, + retryable: athenaError.Retryable ?? null, + errorMessage: athenaError.ErrorMessage ?? null, + } + : null, + executionParameters: execution.ExecutionParameters ?? null, + substatementType: execution.SubstatementType ?? null, + engineVersion: mapEngineVersion(execution.EngineVersion), + } +} + +/** + * Whether the first row of a first results page is the column-header row. SELECT results + * start with one; DDL and utility statements such as SHOW TABLES do not, so their first row + * is data. Without column metadata to compare against, the row is treated as a header, as + * it always was before. + */ +function isHeaderRow(row: Row | undefined, columnInfo: ColumnInfo[]): boolean { + if (!row) return false + if (columnInfo.length === 0) return true + const values = row.Data ?? [] + if (values.length !== columnInfo.length) return false + return values.every((value, index) => { + const column = columnInfo[index] + const cell = value.VarCharValue + return cell !== undefined && (cell === column.Label || cell === column.Name) + }) +} + export async function executeAthenaBatchGetQueryExecution( input: AwsAthenaBatchGetQueryExecutionBody, signal?: AbortSignal @@ -160,6 +205,7 @@ export async function executeAthenaBatchGetQueryExecution( queryQueueTimeInMillis: execution.Statistics?.QueryQueueTimeInMillis ?? null, totalExecutionTimeInMillis: execution.Statistics?.TotalExecutionTimeInMillis ?? null, outputLocation: execution.ResultConfiguration?.OutputLocation ?? null, + ...mapQueryExecutionDetails(execution), })), unprocessedQueryExecutionIds: (response.UnprocessedQueryExecutionIds ?? []).map((item) => ({ queryExecutionId: item.QueryExecutionId ?? null, @@ -250,6 +296,7 @@ export async function executeAthenaGetQueryExecution( queryQueueTimeInMillis: execution.Statistics?.QueryQueueTimeInMillis ?? null, totalExecutionTimeInMillis: execution.Statistics?.TotalExecutionTimeInMillis ?? null, outputLocation: execution.ResultConfiguration?.OutputLocation ?? null, + ...mapQueryExecutionDetails(execution), }, } }) @@ -271,12 +318,19 @@ export async function executeAthenaGetQueryResults( }), { abortSignal: signal } ) - const columns = (response.ResultSet?.ResultSetMetadata?.ColumnInfo ?? []).map((column) => ({ + const columnInfo = response.ResultSet?.ResultSetMetadata?.ColumnInfo ?? [] + const columns = columnInfo.map((column) => ({ name: column.Name ?? '', type: column.Type ?? 'varchar', + label: column.Label ?? null, + precision: column.Precision ?? null, + scale: column.Scale ?? null, + nullable: column.Nullable ?? null, + caseSensitive: column.CaseSensitive ?? null, })) const rawRows = response.ResultSet?.Rows ?? [] - const dataRows = input.nextToken ? rawRows : rawRows.slice(1) + const hasHeaderRow = !input.nextToken && isHeaderRow(rawRows[0], columnInfo) + const dataRows = hasHeaderRow ? rawRows.slice(1) : rawRows const rows = dataRows.map((row) => { const record: Record = {} const rowData = row.Data ?? [] @@ -409,9 +463,27 @@ export async function executeAthenaStartQuery( }, } : {}), - ...(input.outputLocation - ? { ResultConfiguration: { OutputLocation: input.outputLocation } } + ...(input.outputLocation || input.encryptionOption || input.expectedBucketOwner + ? { + ResultConfiguration: { + ...(input.outputLocation ? { OutputLocation: input.outputLocation } : {}), + ...(input.encryptionOption + ? { + EncryptionConfiguration: { + EncryptionOption: input.encryptionOption, + ...(input.kmsKey && input.encryptionOption !== 'SSE_S3' + ? { KmsKey: input.kmsKey } + : {}), + }, + } + : {}), + ...(input.expectedBucketOwner + ? { ExpectedBucketOwner: input.expectedBucketOwner } + : {}), + }, + } : {}), + ...(input.clientRequestToken ? { ClientRequestToken: input.clientRequestToken } : {}), ...(input.workGroup ? { WorkGroup: input.workGroup } : {}), ...(input.executionParameters ? { ExecutionParameters: input.executionParameters } : {}), ...(input.resultReuseEnabled !== undefined diff --git a/apps/sim/lib/internal/aws/credentials.ts b/apps/sim/lib/internal/aws/credentials.ts index eb8a3686b61..1d24f170217 100644 --- a/apps/sim/lib/internal/aws/credentials.ts +++ b/apps/sim/lib/internal/aws/credentials.ts @@ -1,7 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' -import { isRetryableNetworkError } from '@/lib/core/errors/retryable-infrastructure' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { type AwsSessionCredentials, @@ -9,6 +7,7 @@ import { getAwsCredentialConfig, resolveAwsSessionCredentials, } from '@/lib/credentials/aws-service-account.server' +import { awsCredentialFailureMessage, isTransientAwsFailure } from '@/lib/internal/aws/failure' import type { InternalToolOperationCall, InternalToolOperationHandler, @@ -112,27 +111,6 @@ export async function resolveAuthorizedAwsCredential( }) } -const THROTTLING_ERROR_NAMES = new Set(['Throttling', 'ThrottlingException']) - -/** - * Whether resolving a credential failed because AWS was briefly unavailable rather - * than because it denied the credential: an error STS models as retryable - * (IDPCommunicationError, when STS could not reach Sim's OIDC issuer), throttling, - * an STS 5xx, or a network failure. The SDK has already retried these. - */ -function isTransientAwsFailure(error: unknown): boolean { - if (!isRecordLike(error)) return false - const status = isRecordLike(error.$metadata) ? error.$metadata.httpStatusCode : undefined - return ( - error.$retryable !== undefined || - THROTTLING_ERROR_NAMES.has(String(error.name)) || - status === 429 || - (typeof status === 'number' && status >= 500) || - error.name === 'TimeoutError' || - isRetryableNetworkError(error) - ) -} - /** Wraps a family's operation handler so it also accepts a saved AWS credential. */ export function withAwsCredential( handler: InternalToolOperationHandler @@ -152,7 +130,7 @@ export function withAwsCredential( } catch (error) { request.signal?.throwIfAborted() return Response.json( - { error: `Could not use the AWS credential: ${getErrorMessage(error, 'Unknown error')}` }, + { error: awsCredentialFailureMessage(error) }, { status: isTransientAwsFailure(error) ? 503 : 403 } ) } diff --git a/apps/sim/lib/internal/aws/failure.ts b/apps/sim/lib/internal/aws/failure.ts new file mode 100644 index 00000000000..ce5188ac2fc --- /dev/null +++ b/apps/sim/lib/internal/aws/failure.ts @@ -0,0 +1,29 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { isRetryableNetworkError } from '@/lib/core/errors/retryable-infrastructure' + +const THROTTLING_ERROR_NAMES = new Set(['Throttling', 'ThrottlingException']) + +/** + * Whether resolving a saved AWS credential failed because AWS was briefly unavailable rather + * than because it denied the credential: an error STS models as retryable + * (IDPCommunicationError, when STS could not reach Sim's OIDC issuer), throttling, an STS 5xx, + * or a network failure. The SDK has already retried these. + */ +export function isTransientAwsFailure(error: unknown): boolean { + if (!isRecordLike(error)) return false + const status = isRecordLike(error.$metadata) ? error.$metadata.httpStatusCode : undefined + return ( + error.$retryable !== undefined || + THROTTLING_ERROR_NAMES.has(String(error.name)) || + status === 429 || + (typeof status === 'number' && status >= 500) || + error.name === 'TimeoutError' || + isRetryableNetworkError(error) + ) +} + +/** The caller-facing reason a saved AWS credential could not be used: the AWS message, never a key. */ +export function awsCredentialFailureMessage(error: unknown): string { + return `Could not use the AWS credential: ${getErrorMessage(error, 'Unknown error')}` +} diff --git a/apps/sim/lib/internal/azure-data-explorer/client.test.ts b/apps/sim/lib/internal/azure-data-explorer/client.test.ts index 0b352c4002b..c84c9885451 100644 --- a/apps/sim/lib/internal/azure-data-explorer/client.test.ts +++ b/apps/sim/lib/internal/azure-data-explorer/client.test.ts @@ -121,4 +121,57 @@ describe('requestAzureDataExplorer', () => { expect(output.records).toHaveLength(10_000) expect(output).toMatchObject({ rowCount: 10_000, totalRowCount: 10_050, truncated: true }) }) + + it('truncates rows whose rows-plus-records projection would exceed the tool response cap', async () => { + secureFetch.mockReset() + const response = queryResponse() + const wide = 'x'.repeat(2048) + response.Tables[1].Rows = Array.from({ length: 3000 }, () => [wide]) + secureFetch + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-1', expires_in: 3600 })) + .mockResolvedValueOnce(jsonResponse(response)) + + const output = await requestAzureDataExplorer( + { ...BASE_INPUT, clientSecret: 'unique-4' }, + 'request-4' + ) + + expect(output.truncated).toBe(true) + expect(output.totalRowCount).toBe(3000) + expect(output.rowCount).toBeLessThan(3000) + expect(Buffer.byteLength(JSON.stringify({ success: true, output }))).toBeLessThan( + 10 * 1024 * 1024 + ) + }) + + it('returns every QueryResult table a batch produces', async () => { + secureFetch.mockReset() + const response = queryResponse() + response.Tables.splice(2, 0, { + TableName: 'Table_2', + Columns: [{ ColumnName: 'Second', DataType: 'String', ColumnType: 'string' }], + Rows: [['second result']], + }) + response.Tables[4].Rows = [ + [0, 'QueryProperties'], + [1, 'QueryResult'], + [2, 'QueryResult'], + [3, 'QueryStatus'], + ] + secureFetch + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-1', expires_in: 3600 })) + .mockResolvedValueOnce(jsonResponse(response)) + + const output = await requestAzureDataExplorer( + { ...BASE_INPUT, clientSecret: 'unique-5' }, + 'request-5' + ) + + expect(output.records).toEqual([{ Test: 'Hello, World!' }]) + expect(output.additionalResults).toHaveLength(1) + expect(output.additionalResults?.[0]).toMatchObject({ + tableName: 'Table_2', + records: [{ Second: 'second result' }], + }) + }) }) diff --git a/apps/sim/lib/internal/azure-data-explorer/client.ts b/apps/sim/lib/internal/azure-data-explorer/client.ts index c5c71b957b7..a0e68dcd584 100644 --- a/apps/sim/lib/internal/azure-data-explorer/client.ts +++ b/apps/sim/lib/internal/azure-data-explorer/client.ts @@ -6,12 +6,23 @@ import { secureFetchWithValidation, } from '@/lib/core/security/input-validation.server' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + MicrosoftAppCredentialError, + resolveMicrosoftAppResourceToken, +} from '@/lib/credentials/microsoft-app-service-account.server' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { type AzureDataExplorerInput, + type AzureDataExplorerRequest, assertSafeAzureDataExplorerClusterUri, + kustoCredentialClouds, resolveEntraAuthority, } from '@/lib/internal/azure-data-explorer/schema' -import type { AzureDataExplorerTable } from '@/tools/azure_data_explorer/types' +import { MAX_TOOL_RESPONSE_BODY_BYTES } from '@/lib/internal/tool-operations/response-limits' +import type { + AzureDataExplorerResultTable, + AzureDataExplorerTable, +} from '@/tools/azure_data_explorer/types' const logger = createLogger('AzureDataExplorerClient') @@ -22,6 +33,13 @@ const TOKEN_SAFETY_WINDOW_MS = 60_000 const MAX_ERROR_MESSAGE_LENGTH = 2000 const MAX_TOKEN_RESPONSE_BYTES = 256 * 1024 const MAX_PROJECTED_ROWS = 10_000 +/** + * Serialized bytes the projected tables may take together. The projection carries each row twice + * (positional and keyed), so it is bounded by the inline tool response cap, minus each table's + * actual envelope and a small margin for the response wrapper, rather than left to fail there + * with a generic size error. + */ +const MAX_PROJECTED_BYTES = MAX_TOOL_RESPONSE_BODY_BYTES - 16 * 1024 interface CachedToken { accessToken: string @@ -41,7 +59,7 @@ export class AzureDataExplorerOperationError extends Error { } } -function resolveResource(input: AzureDataExplorerInput, clusterUrl: URL): string { +function resolveResource(input: AzureDataExplorerRequest, clusterUrl: URL): string { return (input.resource || clusterUrl.origin).replace(/\/+$/, '') } @@ -144,6 +162,8 @@ function columnNames(table: KustoTable): string[] { interface TableOfContents { primaryOrdinal: number | null statusOrdinal: number | null + /** Ordinals of every QueryResult table, in order; batches and `fork` produce more than one. */ + resultOrdinals: number[] } function readTableOfContents(tables: KustoTable[]): TableOfContents | null { @@ -156,13 +176,17 @@ function readTableOfContents(tables: KustoTable[]): TableOfContents | null { let primaryOrdinal: number | null = null let statusOrdinal: number | null = null + const resultOrdinals: number[] = [] for (const row of contents.Rows ?? []) { const ordinal = Number(row[ordinalIndex]) if (!Number.isInteger(ordinal) || !tables[ordinal]) continue - if (row[kindIndex] === 'QueryResult' && primaryOrdinal === null) primaryOrdinal = ordinal + if (row[kindIndex] === 'QueryResult') { + if (primaryOrdinal === null) primaryOrdinal = ordinal + resultOrdinals.push(ordinal) + } if (row[kindIndex] === 'QueryStatus' && statusOrdinal === null) statusOrdinal = ordinal } - return { primaryOrdinal, statusOrdinal } + return { primaryOrdinal, statusOrdinal, resultOrdinals } } function selectPrimaryTable( @@ -194,7 +218,7 @@ function findQueryFailure(tables: KustoTable[], contents: TableOfContents | null return null } -const EMPTY_PROJECTION: AzureDataExplorerTable = { +const EMPTY_PROJECTION: AzureDataExplorerResultTable = { tableName: null, columns: [], rows: [], @@ -204,22 +228,62 @@ const EMPTY_PROJECTION: AzureDataExplorerTable = { truncated: false, } -function projectTable(table: KustoTable | null): AzureDataExplorerTable { - if (!table) return EMPTY_PROJECTION - const columns = (table.Columns ?? []).map((column) => ({ +interface ProjectionBudget { + rows: number + bytes: number +} + +function projectColumns(table: KustoTable): AzureDataExplorerResultTable['columns'] { + return (table.Columns ?? []).map((column) => ({ name: column.ColumnName ?? '', type: column.ColumnType ?? null, dataType: column.DataType ?? null, })) +} + +/** Serialized size of a table's projection without its rows, reserved before any rows are kept. */ +function envelopeBytes(table: KustoTable | null): number { + if (!table) return Buffer.byteLength(JSON.stringify(EMPTY_PROJECTION)) + const rowTotal = (table.Rows ?? []).length + return Buffer.byteLength( + JSON.stringify({ + tableName: table.TableName ?? null, + columns: projectColumns(table), + rows: [], + records: [], + rowCount: rowTotal, + totalRowCount: rowTotal, + truncated: false, + }) + ) +} + +function projectTable( + table: KustoTable | null, + budget: ProjectionBudget +): AzureDataExplorerResultTable { + if (!table) return EMPTY_PROJECTION + const columns = projectColumns(table) const allRows = table.Rows ?? [] - const rows = allRows.length > MAX_PROJECTED_ROWS ? allRows.slice(0, MAX_PROJECTED_ROWS) : allRows - const records = rows.map((row) => { + const rows: unknown[][] = [] + const records: Array> = [] + for (const row of allRows) { + if (budget.rows <= 0) break const record: Record = {} columns.forEach((column, index) => { if (column.name) record[column.name] = row[index] ?? null }) - return record - }) + const bytes = + Buffer.byteLength(JSON.stringify(row)) + Buffer.byteLength(JSON.stringify(record)) + 2 + if (bytes > budget.bytes) { + budget.rows = 0 + break + } + budget.rows -= 1 + budget.bytes -= bytes + rows.push(row) + records.push(record) + } return { tableName: table.TableName ?? null, columns, @@ -231,6 +295,27 @@ function projectTable(table: KustoTable | null): AzureDataExplorerTable { } } +function projectResults( + tables: KustoTable[], + contents: TableOfContents | null +): AzureDataExplorerTable { + const primaryTable = selectPrimaryTable(tables, contents) + const additionalOrdinals = contents?.resultOrdinals.slice(1) ?? [] + const reserved = [primaryTable, ...additionalOrdinals.map((ordinal) => tables[ordinal] ?? null)] + .map(envelopeBytes) + .reduce((total, bytes) => total + bytes, 0) + const budget: ProjectionBudget = { + rows: MAX_PROJECTED_ROWS, + bytes: MAX_PROJECTED_BYTES - reserved, + } + const primary = projectTable(primaryTable, budget) + if (additionalOrdinals.length === 0) return primary + return { + ...primary, + additionalResults: additionalOrdinals.map((ordinal) => projectTable(tables[ordinal], budget)), + } +} + function extractKustoError(body: unknown, status: number): string { if (body && typeof body === 'object') { const error = (body as { error?: { code?: unknown; message?: unknown } }).error @@ -247,16 +332,110 @@ function extractKustoError(body: unknown, status: number): string { return `Azure Data Explorer request failed with HTTP ${status}` } +/** Signs a request in with the inline client secret it carries. */ export async function requestAzureDataExplorer( input: AzureDataExplorerInput, requestId: string, signal?: AbortSignal +): Promise { + return sendAzureDataExplorerRequest( + input, + (clusterUrl) => + fetchAccessToken( + input, + resolveEntraAuthority(clusterUrl.hostname), + resolveResource(input, clusterUrl), + requestId, + signal + ), + requestId, + signal + ) +} + +/** What a tool caller is told when the Microsoft app credential cannot be used, by refusal code. */ +function appCredentialRefusal(error: TokenServiceAccountValidationError) { + switch (error.code) { + case 'provider_unavailable': + return new AzureDataExplorerOperationError( + 'Microsoft Entra is temporarily unavailable; try again in a moment', + 503 + ) + case 'cloud_mismatch': + return new AzureDataExplorerOperationError( + 'The Microsoft app credential signs in to a different Microsoft cloud than this cluster; choose a credential registered in the cluster’s cloud', + 400 + ) + default: + return new AzureDataExplorerOperationError( + 'Microsoft Entra rejected the Microsoft app credential; check the app registration and reconnect the credential', + 403 + ) + } +} + +/** + * Runs a request as an executor-authorized Microsoft app credential that belongs to `scope`, the + * run's own workspace or organization. The token audience is the allowlisted host of the + * `resource` override or of the cluster, as `https://` (Kusto's documented resource ID, the + * cluster URI without port or path), and both that host and the cluster must be in the + * credential's cloud. + * @see https://learn.microsoft.com/en-us/kusto/api/rest/authenticate-with-msal + */ +export async function requestAzureDataExplorerAsApp( + input: AzureDataExplorerRequest, + credential: { credentialId: string; scope: { workspaceId?: string; organizationId?: string } }, + requestId: string, + signal?: AbortSignal +): Promise { + return sendAzureDataExplorerRequest( + input, + async (clusterUrl) => { + const audienceHost = input.resource + ? assertSafeAzureDataExplorerClusterUri(input.resource, 'resource').hostname + : clusterUrl.hostname + const clusterClouds = kustoCredentialClouds(clusterUrl.hostname) + try { + return await resolveMicrosoftAppResourceToken( + credential.credentialId, + credential.scope, + { + resource: `https://${audienceHost.toLowerCase()}`, + clouds: kustoCredentialClouds(audienceHost).filter((cloud) => + clusterClouds.includes(cloud) + ), + serviceLabel: 'Azure Data Explorer', + }, + signal + ) + } catch (error) { + if (error instanceof MicrosoftAppCredentialError) { + throw new AzureDataExplorerOperationError(error.message, 403) + } + if (!(error instanceof TokenServiceAccountValidationError)) throw error + logger.warn('Microsoft app credential refused for Azure Data Explorer', { + requestId, + code: error.code, + ...error.logDetail, + }) + throw appCredentialRefusal(error) + } + }, + requestId, + signal + ) +} + +async function sendAzureDataExplorerRequest( + input: AzureDataExplorerRequest, + getAccessToken: (clusterUrl: URL) => Promise, + requestId: string, + signal?: AbortSignal ): Promise { signal?.throwIfAborted() const clusterUrl = assertSafeAzureDataExplorerClusterUri(input.clusterUri) - const resource = resolveResource(input, clusterUrl) - const authority = resolveEntraAuthority(clusterUrl.hostname) - const accessToken = await fetchAccessToken(input, authority, resource, requestId, signal) + const accessToken = await getAccessToken(clusterUrl) + signal?.throwIfAborted() const headers: Record = { Authorization: `Bearer ${accessToken}`, @@ -319,5 +498,5 @@ export async function requestAzureDataExplorer( if (failure) { throw new AzureDataExplorerOperationError(truncate(failure, MAX_ERROR_MESSAGE_LENGTH), 400, 200) } - return projectTable(selectPrimaryTable(tables, contents)) + return projectResults(tables, contents) } diff --git a/apps/sim/lib/internal/azure-data-explorer/execute-tool.test.ts b/apps/sim/lib/internal/azure-data-explorer/execute-tool.test.ts new file mode 100644 index 00000000000..20b005e8212 --- /dev/null +++ b/apps/sim/lib/internal/azure-data-explorer/execute-tool.test.ts @@ -0,0 +1,268 @@ +/** + * @vitest-environment node + */ +import { credential } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { jsonResponse } from '@sim/testing/helpers/http' +import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { executeAzureDataExplorerTool } from '@/lib/internal/azure-data-explorer/execute-tool' +import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' + +const { mockSecureFetchWithValidation: secureFetch } = inputValidationMockFns + +const TENANT = '11111111-2222-3333-4444-555555555555' +const CLIENT_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' +const CLUSTER = 'https://mycluster.eastus.kusto.windows.net' + +const QUERY_RESULT = { + Tables: [ + { + TableName: 'Table_0', + Columns: [{ ColumnName: 'Test', DataType: 'String', ColumnType: 'string' }], + Rows: [['Hello']], + }, + ], +} + +let secretVersion = 0 + +/** Stores a fresh secret version each time, so no test reuses another's cached token. */ +function storeAppCredential(config: Record = {}) { + secretVersion += 1 + resetDbChainMock() + queueTableRows(credential, [ + { + workspaceId: 'ws-1', + organizationId: null, + type: 'service_account', + providerId: 'microsoft-app-service-account', + revokedAt: null, + encryptedServiceAccountKey: `ciphertext-${secretVersion}`, + }, + ]) + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ + type: 'microsoft_app_credential_v1', + config: { + authType: 'client_secret', + tenantId: TENANT, + clientId: CLIENT_ID, + clientSecret: 'app-secret', + ...config, + }, + }), + }) +} + +function run( + input: Record, + authorized?: InternalToolOperationContext['authorizedServiceAccountCredential'] +) { + return executeAzureDataExplorerTool({ + toolId: 'azure_data_explorer_query', + input: { endpoint: 'query', database: 'Samples', csl: 'print Test="Hello"', ...input }, + headers: new Headers(), + requestId: 'request-1', + context: { + workflowId: 'wf-1', + workspaceId: 'ws-1', + userId: 'user-1', + ...(authorized ? { authorizedServiceAccountCredential: authorized } : {}), + }, + }) +} + +describe('running an Azure Data Explorer tool', () => { + const entra = vi.fn() + + beforeEach(() => { + secureFetch.mockReset() + encryptionMockFns.mockDecryptSecret.mockReset() + entra.mockReset().mockImplementation(async (url: string, init: RequestInit) => { + const scope = new URLSearchParams(String(init.body)).get('scope') + return jsonResponse({ access_token: `at:${url}|${scope}`, expires_in: 3599 }) + }) + vi.stubGlobal('fetch', entra) + }) + + describe('as an authorized Microsoft app credential', () => { + const authorized = { credentialId: 'cred-adx', service: 'azure-data-explorer' } + + it('needs no inline secret and calls the cluster with a token minted for it', async () => { + storeAppCredential() + secureFetch.mockResolvedValueOnce(jsonResponse(QUERY_RESULT)) + + const response = await run({ clusterUri: `${CLUSTER}/` }, authorized) + + expect(response.status).toBe(200) + expect(entra).toHaveBeenCalledTimes(1) + expect(entra.mock.calls[0][0]).toBe( + `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token` + ) + expect(Object.fromEntries(new URLSearchParams(String(entra.mock.calls[0][1].body)))).toEqual({ + client_id: CLIENT_ID, + client_secret: 'app-secret', + grant_type: 'client_credentials', + scope: `${CLUSTER}/.default`, + }) + expect(secureFetch).toHaveBeenCalledTimes(1) + expect(secureFetch.mock.calls[0][0]).toBe(`${CLUSTER}/v1/rest/query`) + expect(secureFetch.mock.calls[0][1].headers.Authorization).toBe( + `Bearer at:https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token|${CLUSTER}/.default` + ) + }) + + it('ignores inline sign-in fields, so the credential decides who the request runs as', async () => { + storeAppCredential() + secureFetch.mockResolvedValueOnce(jsonResponse(QUERY_RESULT)) + + const response = await run( + { + clusterUri: CLUSTER, + tenantId: 'other-tenant', + clientId: 'other-client', + clientSecret: 'other-secret', + }, + authorized + ) + + expect(response.status).toBe(200) + expect(String(entra.mock.calls[0][0])).toContain(TENANT) + expect(String(entra.mock.calls[0][1].body)).not.toContain('other-secret') + }) + + it('takes the token audience from the host of a resource override, without its path', async () => { + storeAppCredential() + secureFetch.mockResolvedValueOnce(jsonResponse(QUERY_RESULT)) + const audience = 'https://help.kusto.windows.net' + + await run({ clusterUri: CLUSTER, resource: `${audience}/some/path` }, authorized) + + expect(new URLSearchParams(String(entra.mock.calls[0][1].body)).get('scope')).toBe( + `${audience}/.default` + ) + }) + + it('refuses a cluster outside the credential’s cloud before any request', async () => { + storeAppCredential({ cloud: 'usgov' }) + + const response = await run({ clusterUri: CLUSTER }, authorized) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: expect.stringMatching(/different Microsoft cloud/), + }) + expect(entra).not.toHaveBeenCalled() + expect(secureFetch).not.toHaveBeenCalled() + }) + + it('refuses a credential that belongs to another workspace than the run, before decrypting', async () => { + storeAppCredential() + + const response = await executeAzureDataExplorerTool({ + toolId: 'azure_data_explorer_query', + input: { endpoint: 'query', database: 'Samples', csl: 'print 1', clusterUri: CLUSTER }, + headers: new Headers(), + requestId: 'request-1', + context: { + workflowId: 'wf-1', + workspaceId: 'ws-2', + userId: 'user-1', + authorizedServiceAccountCredential: authorized, + }, + }) + + expect(response.status).toBe(403) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + expect(entra).not.toHaveBeenCalled() + expect(secureFetch).not.toHaveBeenCalled() + }) + + it('asks for a reconnect when Entra rejects the app, without echoing Entra’s detail', async () => { + storeAppCredential() + entra.mockResolvedValueOnce( + jsonResponse( + { error: 'invalid_client', error_description: 'AADSTS7000215: Invalid client secret' }, + { status: 401 } + ) + ) + + const response = await run({ clusterUri: CLUSTER }, authorized) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toMatch(/reconnect/) + expect(body.error).not.toMatch(/AADSTS/) + expect(secureFetch).not.toHaveBeenCalled() + }) + + it('signs in at the national cloud of a credential that matches the cluster', async () => { + storeAppCredential({ cloud: 'usgov_dod' }) + secureFetch.mockResolvedValueOnce(jsonResponse(QUERY_RESULT)) + const govCluster = 'https://mycluster.usgovvirginia.kusto.usgovcloudapi.net' + + const response = await run({ clusterUri: govCluster }, authorized) + + expect(response.status).toBe(200) + expect(entra.mock.calls[0][0]).toBe( + `https://login.microsoftonline.us/${TENANT}/oauth2/v2.0/token` + ) + }) + + it('refuses a resource override in another cloud than the cluster before decrypting', async () => { + storeAppCredential() + + const response = await run( + { + clusterUri: CLUSTER, + resource: 'https://mycluster.usgovvirginia.kusto.usgovcloudapi.net', + }, + authorized + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringMatching(/same Microsoft cloud/), + }) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + expect(entra).not.toHaveBeenCalled() + expect(secureFetch).not.toHaveBeenCalled() + }) + + it('refuses a host outside the Kusto allowlist without minting', async () => { + const response = await run({ clusterUri: 'https://evil.example.com' }, authorized) + + expect(response.status).toBe(400) + expect(entra).not.toHaveBeenCalled() + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + }) + + it('still requires the inline client secret when no credential was authorized', async () => { + const response = await run({ clusterUri: CLUSTER, tenantId: TENANT, clientId: CLIENT_ID }) + + expect(response.status).toBe(400) + expect(entra).not.toHaveBeenCalled() + expect(secureFetch).not.toHaveBeenCalled() + }) + + it('keeps the inline client secret path for a credential authorized for another service', async () => { + const response = await run( + { clusterUri: CLUSTER, tenantId: TENANT, clientId: CLIENT_ID }, + { credentialId: 'cred-aws', service: 'aws' } + ) + + expect(response.status).toBe(400) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/azure-data-explorer/execute-tool.ts b/apps/sim/lib/internal/azure-data-explorer/execute-tool.ts index 9ad5bfdea50..4ff92d2100f 100644 --- a/apps/sim/lib/internal/azure-data-explorer/execute-tool.ts +++ b/apps/sim/lib/internal/azure-data-explorer/execute-tool.ts @@ -2,10 +2,21 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { getValidationErrorMessage } from '@/lib/api/server' import { DEFAULT_MAX_JSON_BODY_BYTES } from '@/lib/api/server/validation' -import { AzureDataExplorerOperationError } from '@/lib/internal/azure-data-explorer/client' -import { executeAzureDataExplorerOperation } from '@/lib/internal/azure-data-explorer/operations' -import { azureDataExplorerInputSchema } from '@/lib/internal/azure-data-explorer/schema' -import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' +import { + AzureDataExplorerOperationError, + requestAzureDataExplorer, + requestAzureDataExplorerAsApp, +} from '@/lib/internal/azure-data-explorer/client' +import { + azureDataExplorerInputSchema, + azureDataExplorerRequestSchema, +} from '@/lib/internal/azure-data-explorer/schema' +import type { + InternalToolOperationCall, + InternalToolOperationHandler, +} from '@/lib/internal/tool-operations/types' +import type { AzureDataExplorerTable } from '@/tools/azure_data_explorer/types' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' const logger = createLogger('AzureDataExplorerToolExecution') @@ -34,6 +45,50 @@ function exceedsInputCap(input: unknown): boolean { } } +type PreparedOperation = + | { ok: true; run: () => Promise } + | { ok: false; error: string } + +/** + * Parses the input for the identity the request runs as. The executor sets + * `authorizedServiceAccountCredential` only after authorizing a saved Microsoft app credential, + * never from tool input; with one, the request runs as that credential, re-checked against the + * run's own workspace or organization, and any inline sign-in fields are dropped unread. Without one, the inline client secret is required, as always. + */ +function prepareOperation(request: InternalToolOperationCall): PreparedOperation { + const authorized = request.context.authorizedServiceAccountCredential + if (authorized?.service === AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH.provider) { + const parsed = azureDataExplorerRequestSchema.safeParse(request.input) + if (!parsed.success) { + return { ok: false, error: getValidationErrorMessage(parsed.error, 'Validation failed') } + } + return { + ok: true, + run: () => + requestAzureDataExplorerAsApp( + parsed.data, + { + credentialId: authorized.credentialId, + scope: { + workspaceId: request.context.workspaceId, + organizationId: request.context.organizationId, + }, + }, + request.requestId, + request.signal + ), + } + } + const parsed = azureDataExplorerInputSchema.safeParse(request.input) + if (!parsed.success) { + return { ok: false, error: getValidationErrorMessage(parsed.error, 'Validation failed') } + } + return { + ok: true, + run: () => requestAzureDataExplorer(parsed.data, request.requestId, request.signal), + } +} + export const executeAzureDataExplorerTool: InternalToolOperationHandler = async (request) => { request.signal?.throwIfAborted() if (!TOOL_IDS.has(request.toolId)) { @@ -55,23 +110,13 @@ export const executeAzureDataExplorerTool: InternalToolOperationHandler = async ) } - const parsed = azureDataExplorerInputSchema.safeParse(request.input) - if (!parsed.success) { - return Response.json( - { - success: false, - error: getValidationErrorMessage(parsed.error, 'Validation failed'), - }, - { status: 400 } - ) + const prepared = prepareOperation(request) + if (!prepared.ok) { + return Response.json({ success: false, error: prepared.error }, { status: 400 }) } try { - const output = await executeAzureDataExplorerOperation( - parsed.data, - request.requestId, - request.signal - ) + const output = await prepared.run() request.signal?.throwIfAborted() return Response.json({ success: true, output }) } catch (error) { diff --git a/apps/sim/lib/internal/azure-data-explorer/operations.ts b/apps/sim/lib/internal/azure-data-explorer/operations.ts deleted file mode 100644 index 964d367f47b..00000000000 --- a/apps/sim/lib/internal/azure-data-explorer/operations.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { requestAzureDataExplorer } from '@/lib/internal/azure-data-explorer/client' -import type { AzureDataExplorerInput } from '@/lib/internal/azure-data-explorer/schema' - -export async function executeAzureDataExplorerOperation( - input: AzureDataExplorerInput, - requestId: string, - signal?: AbortSignal -) { - return requestAzureDataExplorer(input, requestId, signal) -} diff --git a/apps/sim/lib/internal/azure-data-explorer/schema.ts b/apps/sim/lib/internal/azure-data-explorer/schema.ts index 6e99c7df1a4..a9e9903b411 100644 --- a/apps/sim/lib/internal/azure-data-explorer/schema.ts +++ b/apps/sim/lib/internal/azure-data-explorer/schema.ts @@ -1,12 +1,20 @@ import { isPrivateIpHost } from '@sim/security/ssrf' import { z } from 'zod' +import { MICROSOFT_CLOUDS, type MicrosoftCloudId } from '@/lib/credentials/microsoft-clouds' -const KUSTO_CLOUDS = [ - { hostSuffix: 'kusto.windows.net', authority: 'https://login.microsoftonline.com' }, - { hostSuffix: 'kusto.fabric.microsoft.com', authority: 'https://login.microsoftonline.com' }, - { hostSuffix: 'kusto.usgovcloudapi.net', authority: 'https://login.microsoftonline.us' }, - { hostSuffix: 'kusto.chinacloudapi.cn', authority: 'https://login.partner.microsoftonline.cn' }, -] as const +/** + * Cluster host suffixes per Azure cloud, and the Microsoft app credential clouds whose tokens the + * cluster accepts. The first of those clouds' Entra endpoint signs in an inline client secret. + */ +const KUSTO_CLOUDS: readonly { + hostSuffix: string + credentialClouds: readonly [MicrosoftCloudId, ...MicrosoftCloudId[]] +}[] = [ + { hostSuffix: 'kusto.windows.net', credentialClouds: ['global'] }, + { hostSuffix: 'kusto.fabric.microsoft.com', credentialClouds: ['global'] }, + { hostSuffix: 'kusto.usgovcloudapi.net', credentialClouds: ['usgov', 'usgov_dod'] }, + { hostSuffix: 'kusto.chinacloudapi.cn', credentialClouds: ['china'] }, +] const ALLOWED_CLUSTER_HOSTS = KUSTO_CLOUDS.map((cloud) => cloud.hostSuffix).join(', ') @@ -18,12 +26,17 @@ function matchKustoCloud(host: string): (typeof KUSTO_CLOUDS)[number] | null { ) } +/** The Microsoft app credential clouds an allowlisted cluster host accepts tokens from. */ +export function kustoCredentialClouds(clusterHost: string): readonly MicrosoftCloudId[] { + return matchKustoCloud(clusterHost.toLowerCase())?.credentialClouds ?? [] +} + export function resolveEntraAuthority(clusterHost: string): string { const cloud = matchKustoCloud(clusterHost.toLowerCase()) if (!cloud) { throw new Error(`No Microsoft Entra authority is configured for cluster host ${clusterHost}`) } - return cloud.authority + return MICROSOFT_CLOUDS[cloud.credentialClouds[0]].loginOrigin } export function checkAzureDataExplorerClusterUri( @@ -81,37 +94,69 @@ const tenantIdSchema = z 'tenantId must be a GUID or a domain name (e.g., contoso.onmicrosoft.com)' ) +/** What every request carries, however it authenticates. */ +const requestShape = { + clusterUri: z.string().min(1, 'clusterUri is required'), + resource: z.string().optional(), + endpoint: z.enum(['query', 'mgmt']), + database: entityNameSchema.optional(), + csl: z.string().min(1, 'csl is required').max(1_000_000, 'csl is too long'), + properties: z.record(z.string(), z.unknown()).optional(), + readOnly: z.boolean().optional(), +} + +function refineHosts( + input: { clusterUri: string; resource?: string }, + context: z.RefinementCtx +): void { + const clusterCheck = checkAzureDataExplorerClusterUri(input.clusterUri) + if (!clusterCheck.ok) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clusterUri'], + message: clusterCheck.message, + }) + } + if (input.resource === undefined) return + const resourceCheck = checkAzureDataExplorerClusterUri(input.resource, 'resource') + if (!resourceCheck.ok) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['resource'], + message: resourceCheck.message, + }) + return + } + if (!clusterCheck.ok) return + const clusterClouds = kustoCredentialClouds(clusterCheck.url.hostname) + const sharesCloud = kustoCredentialClouds(resourceCheck.url.hostname).some((cloud) => + clusterClouds.includes(cloud) + ) + if (!sharesCloud) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['resource'], + message: 'resource must be in the same Microsoft cloud as clusterUri', + }) + } +} + +/** A request that signs in with the inline client secret it carries. */ export const azureDataExplorerInputSchema = z .object({ - clusterUri: z.string().min(1, 'clusterUri is required'), + ...requestShape, tenantId: tenantIdSchema, clientId: z.string().min(1, 'clientId is required'), clientSecret: z.string().min(1, 'clientSecret is required'), - resource: z.string().optional(), - endpoint: z.enum(['query', 'mgmt']), - database: entityNameSchema.optional(), - csl: z.string().min(1, 'csl is required').max(1_000_000, 'csl is too long'), - properties: z.record(z.string(), z.unknown()).optional(), - readOnly: z.boolean().optional(), - }) - .superRefine((input, context) => { - const clusterCheck = checkAzureDataExplorerClusterUri(input.clusterUri) - if (!clusterCheck.ok) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['clusterUri'], - message: clusterCheck.message, - }) - } - if (input.resource === undefined) return - const resourceCheck = checkAzureDataExplorerClusterUri(input.resource, 'resource') - if (!resourceCheck.ok) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['resource'], - message: resourceCheck.message, - }) - } }) + .superRefine(refineHosts) export type AzureDataExplorerInput = z.output + +/** + * A request that runs as the executor-authorized Microsoft app credential. Any inline sign-in + * fields are dropped unread: the credential, not the caller, decides who the request runs as. + */ +export const azureDataExplorerRequestSchema = z.object(requestShape).superRefine(refineHosts) + +export type AzureDataExplorerRequest = z.output diff --git a/apps/sim/lib/internal/cloudformation/operations.ts b/apps/sim/lib/internal/cloudformation/operations.ts index f208c873621..2afe5cd7c4c 100644 --- a/apps/sim/lib/internal/cloudformation/operations.ts +++ b/apps/sim/lib/internal/cloudformation/operations.ts @@ -76,6 +76,8 @@ export async function executeCloudformationCreateChangeSet( Capabilities: parseCapabilities(input.capabilities), ChangeSetType: input.changeSetType, Description: input.description, + Tags: toStackTags(input.tags), + RoleARN: input.roleArn, }), { abortSignal: signal } ) @@ -106,6 +108,8 @@ export async function executeCloudformationCreateStack( Tags: toStackTags(input.tags), OnFailure: input.onFailure, TimeoutInMinutes: input.timeoutInMinutes, + RoleARN: input.roleArn, + EnableTerminationProtection: input.enableTerminationProtection, }), { abortSignal: signal } ) @@ -153,6 +157,7 @@ export async function executeCloudformationDescribeChangeSet( new DescribeChangeSetCommand({ ChangeSetName: input.changeSetName, ...(input.stackName ? { StackName: input.stackName } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), }), { abortSignal: signal } ) @@ -162,6 +167,18 @@ export async function executeCloudformationDescribeChangeSet( physicalResourceId: change.ResourceChange?.PhysicalResourceId, resourceType: change.ResourceChange?.ResourceType, replacement: change.ResourceChange?.Replacement, + type: change.Type, + policyAction: change.ResourceChange?.PolicyAction, + scope: change.ResourceChange?.Scope ?? [], + changeSetId: change.ResourceChange?.ChangeSetId, + details: (change.ResourceChange?.Details ?? []).map((detail) => ({ + attribute: detail.Target?.Attribute, + name: detail.Target?.Name, + requiresRecreation: detail.Target?.RequiresRecreation, + evaluation: detail.Evaluation, + changeSource: detail.ChangeSource, + causingEntity: detail.CausingEntity, + })), })) return { success: true, @@ -177,6 +194,20 @@ export async function executeCloudformationDescribeChangeSet( creationTime: response.CreationTime?.getTime(), capabilities: response.Capabilities ?? [], changes, + parameters: (response.Parameters ?? []).map((parameter) => ({ + parameterKey: parameter.ParameterKey ?? '', + parameterValue: parameter.ParameterValue, + usePreviousValue: parameter.UsePreviousValue, + resolvedValue: parameter.ResolvedValue, + })), + tags: (response.Tags ?? []).map((tag) => ({ key: tag.Key ?? '', value: tag.Value ?? '' })), + notificationArns: response.NotificationARNs ?? [], + includeNestedStacks: response.IncludeNestedStacks, + parentChangeSetId: response.ParentChangeSetId, + rootChangeSetId: response.RootChangeSetId, + onStackFailure: response.OnStackFailure, + importExistingResources: response.ImportExistingResources, + nextToken: response.NextToken ?? null, }, } } finally { @@ -244,6 +275,15 @@ export async function executeCloudformationDescribeStackEvents( resourceStatus: event.ResourceStatus, resourceStatusReason: event.ResourceStatusReason, timestamp: event.Timestamp?.getTime(), + operationId: event.OperationId, + resourceProperties: event.ResourceProperties, + clientRequestToken: event.ClientRequestToken, + detailedStatus: event.DetailedStatus, + hookType: event.HookType, + hookStatus: event.HookStatus, + hookStatusReason: event.HookStatusReason, + hookInvocationPoint: event.HookInvocationPoint, + hookFailureMode: event.HookFailureMode, })) return { success: true, output: { events } } } finally { @@ -290,8 +330,27 @@ export async function executeCloudformationDescribeStacks( outputKey: output.OutputKey ?? '', outputValue: output.OutputValue ?? '', description: output.Description, + exportName: output.ExportName, })), tags: (stack.Tags ?? []).map((tag) => ({ key: tag.Key ?? '', value: tag.Value ?? '' })), + parameters: (stack.Parameters ?? []).map((parameter) => ({ + parameterKey: parameter.ParameterKey ?? '', + parameterValue: parameter.ParameterValue, + usePreviousValue: parameter.UsePreviousValue, + resolvedValue: parameter.ResolvedValue, + })), + capabilities: stack.Capabilities ?? [], + changeSetId: stack.ChangeSetId, + deletionTime: stack.DeletionTime?.getTime(), + disableRollback: stack.DisableRollback, + notificationArns: stack.NotificationARNs ?? [], + timeoutInMinutes: stack.TimeoutInMinutes, + roleArn: stack.RoleARN, + parentId: stack.ParentId, + rootId: stack.RootId, + detailedStatus: stack.DetailedStatus, + deletionMode: stack.DeletionMode, + retainExceptOnCreate: stack.RetainExceptOnCreate, })) return { success: true, output: { stacks } } } finally { @@ -366,6 +425,7 @@ export async function executeCloudformationGetTemplateSummary( parameterType: parameter.ParameterType, noEcho: parameter.NoEcho, description: parameter.Description, + allowedValues: parameter.ParameterConstraints?.AllowedValues ?? [], })), capabilities: response.Capabilities ?? [], capabilitiesReason: response.CapabilitiesReason, @@ -458,6 +518,7 @@ export async function executeCloudformationUpdateStack( Parameters: toStackParameters(input.parameters), Capabilities: parseCapabilities(input.capabilities), Tags: toStackTags(input.tags), + RoleARN: input.roleArn, }), { abortSignal: signal } ) diff --git a/apps/sim/lib/internal/cloudwatch/client.ts b/apps/sim/lib/internal/cloudwatch/client.ts index ab5e8584cdb..fc47b9d1488 100644 --- a/apps/sim/lib/internal/cloudwatch/client.ts +++ b/apps/sim/lib/internal/cloudwatch/client.ts @@ -220,6 +220,7 @@ interface FilteredLogEventResult { timestamp: number | undefined message: string | undefined ingestionTime: number | undefined + eventId: string | undefined } /** @@ -229,6 +230,8 @@ interface FilteredLogEventResult { * * When `limit` is provided it is treated as a total result cap: draining stops * once enough events have been collected. When omitted, every page is drained. + * Each page requests at most the remaining cap, so the returned `nextToken` + * resumes exactly after the last returned event. */ export async function filterLogEvents( client: CloudWatchLogsClient, @@ -240,12 +243,13 @@ export async function filterLogEvents( endTime?: number startFromHead?: boolean limit?: number + nextToken?: string }, signal?: AbortSignal -): Promise<{ events: FilteredLogEventResult[] }> { +): Promise<{ events: FilteredLogEventResult[]; nextToken: string | undefined }> { const totalLimit = options?.limit const events: FilteredLogEventResult[] = [] - let nextToken: string | undefined + let nextToken: string | undefined = options?.nextToken for (let page = 0; page < MAX_FILTER_LOG_EVENTS_PAGES; page++) { const pageLimit = @@ -272,6 +276,7 @@ export async function filterLogEvents( timestamp: e.timestamp, message: e.message, ingestionTime: e.ingestionTime, + eventId: e.eventId, }) } @@ -289,6 +294,7 @@ export async function filterLogEvents( return { events: totalLimit !== undefined ? events.slice(0, totalLimit) : events, + nextToken, } } @@ -296,7 +302,7 @@ export async function getLogEvents( client: CloudWatchLogsClient, logGroupName: string, logStreamName: string, - options?: { startTime?: number; endTime?: number; limit?: number }, + options?: { startTime?: number; endTime?: number; limit?: number; nextToken?: string }, signal?: AbortSignal ): Promise<{ events: { @@ -304,6 +310,8 @@ export async function getLogEvents( message: string | undefined ingestionTime: number | undefined }[] + nextForwardToken: string | undefined + nextBackwardToken: string | undefined }> { const command = new GetLogEventsCommand({ logGroupName, @@ -311,6 +319,7 @@ export async function getLogEvents( ...(options?.startTime !== undefined && { startTime: options.startTime * 1000 }), ...(options?.endTime !== undefined && { endTime: options.endTime * 1000 }), ...(options?.limit !== undefined && { limit: options.limit }), + ...(options?.nextToken && { nextToken: options.nextToken }), startFromHead: true, }) @@ -321,5 +330,7 @@ export async function getLogEvents( message: e.message, ingestionTime: e.ingestionTime, })), + nextForwardToken: response.nextForwardToken, + nextBackwardToken: response.nextBackwardToken, } } diff --git a/apps/sim/lib/internal/cloudwatch/operations.ts b/apps/sim/lib/internal/cloudwatch/operations.ts index ac578069def..4d498ec2654 100644 --- a/apps/sim/lib/internal/cloudwatch/operations.ts +++ b/apps/sim/lib/internal/cloudwatch/operations.ts @@ -63,6 +63,8 @@ export async function executeCloudwatchDescribeAlarmHistory( timestamp: number | undefined historyItemType: string | undefined historySummary: string | undefined + historyData: string | undefined + alarmContributorId: string | undefined }[] = [] let nextToken: string | undefined @@ -92,6 +94,8 @@ export async function executeCloudwatchDescribeAlarmHistory( timestamp: item.Timestamp?.getTime(), historyItemType: item.HistoryItemType, historySummary: item.HistorySummary, + historyData: item.HistoryData, + alarmContributorId: item.AlarmContributorId, }) } nextToken = response.NextToken @@ -128,6 +132,7 @@ export async function executeCloudwatchDescribeAlarms( ? [input.alarmType as AlarmType] : (['MetricAlarm', 'CompositeAlarm'] as AlarmType[]), ...(input.limit !== undefined && { MaxRecords: input.limit }), + ...(input.nextToken && { NextToken: input.nextToken }), }), { abortSignal: signal } ) @@ -142,6 +147,48 @@ export async function executeCloudwatchDescribeAlarms( threshold: alarm.Threshold, evaluationPeriods: alarm.EvaluationPeriods, stateUpdatedTimestamp: alarm.StateUpdatedTimestamp?.getTime(), + alarmType: 'MetricAlarm', + alarmDescription: alarm.AlarmDescription, + actionsEnabled: alarm.ActionsEnabled, + alarmActions: alarm.AlarmActions ?? [], + okActions: alarm.OKActions ?? [], + insufficientDataActions: alarm.InsufficientDataActions ?? [], + stateReasonData: alarm.StateReasonData, + stateTransitionedTimestamp: alarm.StateTransitionedTimestamp?.getTime(), + alarmConfigurationUpdatedTimestamp: alarm.AlarmConfigurationUpdatedTimestamp?.getTime(), + statistic: alarm.Statistic, + extendedStatistic: alarm.ExtendedStatistic, + dimensions: (alarm.Dimensions ?? []).map((dimension) => ({ + name: dimension.Name ?? '', + value: dimension.Value ?? '', + })), + period: alarm.Period, + unit: alarm.Unit, + datapointsToAlarm: alarm.DatapointsToAlarm, + treatMissingData: alarm.TreatMissingData, + thresholdMetricId: alarm.ThresholdMetricId, + metrics: alarm.Metrics?.map((query) => ({ + id: query.Id ?? '', + expression: query.Expression, + label: query.Label, + returnData: query.ReturnData, + period: query.Period, + accountId: query.AccountId, + metricStat: query.MetricStat + ? { + namespace: query.MetricStat.Metric?.Namespace, + metricName: query.MetricStat.Metric?.MetricName, + dimensions: (query.MetricStat.Metric?.Dimensions ?? []).map((dimension) => ({ + name: dimension.Name ?? '', + value: dimension.Value ?? '', + })), + period: query.MetricStat.Period, + stat: query.MetricStat.Stat, + unit: query.MetricStat.Unit, + } + : undefined, + })), + alarmRule: undefined, })) const compositeAlarms = (response.CompositeAlarms ?? []).map((alarm) => ({ alarmName: alarm.AlarmName ?? '', @@ -154,8 +201,24 @@ export async function executeCloudwatchDescribeAlarms( threshold: undefined, evaluationPeriods: undefined, stateUpdatedTimestamp: alarm.StateUpdatedTimestamp?.getTime(), + alarmType: 'CompositeAlarm', + alarmDescription: alarm.AlarmDescription, + actionsEnabled: alarm.ActionsEnabled, + alarmActions: alarm.AlarmActions ?? [], + okActions: alarm.OKActions ?? [], + insufficientDataActions: alarm.InsufficientDataActions ?? [], + stateReasonData: alarm.StateReasonData, + stateTransitionedTimestamp: alarm.StateTransitionedTimestamp?.getTime(), + alarmConfigurationUpdatedTimestamp: alarm.AlarmConfigurationUpdatedTimestamp?.getTime(), + alarmRule: alarm.AlarmRule, })) - return { success: true, output: { alarms: [...metricAlarms, ...compositeAlarms] } } + return { + success: true, + output: { + alarms: [...metricAlarms, ...compositeAlarms], + nextToken: response.NextToken ?? null, + }, + } } finally { client.destroy() } @@ -204,10 +267,14 @@ export async function executeCloudwatchFilterLogEvents( endTime: input.endTime !== undefined ? input.endTime * 1000 : undefined, startFromHead: input.startFromHead, limit: input.limit, + nextToken: input.nextToken, }, signal ) - return { success: true, output: { events: result.events } } + return { + success: true, + output: { events: result.events, nextToken: result.nextToken ?? null }, + } } finally { client.destroy() } @@ -223,10 +290,22 @@ export async function executeCloudwatchGetLogEvents( client, input.logGroupName, input.logStreamName, - { startTime: input.startTime, endTime: input.endTime, limit: input.limit }, + { + startTime: input.startTime, + endTime: input.endTime, + limit: input.limit, + nextToken: input.nextToken, + }, signal ) - return { success: true, output: { events: result.events } } + return { + success: true, + output: { + events: result.events, + nextForwardToken: result.nextForwardToken ?? null, + nextBackwardToken: result.nextBackwardToken ?? null, + }, + } } finally { client.destroy() } @@ -265,7 +344,10 @@ export async function executeCloudwatchGetMetricStatistics( StartTime: new Date(input.startTime * 1000), EndTime: new Date(input.endTime * 1000), Period: input.period, - Statistics: input.statistics, + ...(input.extendedStatistics && input.extendedStatistics.length > 0 + ? { ExtendedStatistics: input.extendedStatistics } + : { Statistics: input.statistics }), + ...(input.unit && { Unit: input.unit as StandardUnit }), ...(dimensions && { Dimensions: dimensions }), }), { abortSignal: signal } @@ -280,6 +362,7 @@ export async function executeCloudwatchGetMetricStatistics( maximum: datapoint.Maximum, sampleCount: datapoint.SampleCount, unit: datapoint.Unit, + extendedStatistics: datapoint.ExtendedStatistics, })) return { success: true, @@ -488,6 +571,7 @@ export async function executeCloudwatchQueryLogs( results: result.results, statistics: result.statistics, status: result.status, + queryId: response.queryId, }, } } finally { diff --git a/apps/sim/lib/internal/confluence/operations.test.ts b/apps/sim/lib/internal/confluence/operations.test.ts index a867c8f84e8..363b0d21c88 100644 --- a/apps/sim/lib/internal/confluence/operations.test.ts +++ b/apps/sim/lib/internal/confluence/operations.test.ts @@ -21,7 +21,9 @@ const { mockDownloadServableFileFromStorage } = fileUtilsServerMockFns import { ConfluenceOperationError } from '@/lib/internal/confluence/errors' import { + executeConfluenceListComments, executeConfluenceListPagesInSpace, + executeConfluenceSearch, executeConfluenceSearchInSpace, executeConfluenceUploadAttachment, } from '@/lib/internal/confluence/operations' @@ -97,7 +99,7 @@ describe('Confluence operations', () => { { ...CONNECTION, spaceKey: '12345', query: 'release notes', limit: 25 }, { headers: new Headers(), requestId: 'request-1' } ) - ).resolves.toEqual({ results: [], spaceKey: 'ENG', totalSize: 0 }) + ).resolves.toEqual({ results: [], spaceKey: 'ENG', totalSize: 0, nextCursor: null }) expect(fetchMock).toHaveBeenCalledTimes(2) const searchUrl = String(fetchMock.mock.calls[1][0]) @@ -106,6 +108,128 @@ describe('Confluence operations', () => { ) }) + it('reads comment author and creation time from the version object', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + results: [ + { + id: '77', + status: 'current', + title: 'Re: Plan', + pageId: '12345', + version: { number: 1, authorId: 'acct-1', createdAt: '2026-11-01T10:00:00.000Z' }, + body: { + atlas_doc_format: { value: '{"type":"doc"}', representation: 'atlas_doc_format' }, + }, + }, + ], + _links: {}, + }) + ) + ) + + const result = await executeConfluenceListComments( + { ...CONNECTION, pageId: '12345', limit: '25', bodyFormat: 'atlas_doc_format' }, + { headers: new Headers(), requestId: 'request-1' } + ) + + expect(result.comments[0]).toMatchObject({ + authorId: 'acct-1', + createdAt: '2026-11-01T10:00:00.000Z', + body: { value: '{"type":"doc"}', representation: 'atlas_doc_format' }, + }) + }) + + it('returns the space key of each search result', async () => { + const fetchMock = vi.fn(async (request: string | URL | Request) => { + const expand = new URL(String(request)).searchParams.get('expand') + return Response.json({ + results: [ + { + content: { + id: '98765', + type: 'page', + title: 'Release notes', + ...(expand === 'content.space' + ? { space: { id: 4242, key: 'ENG', name: 'Engineering' } } + : {}), + }, + resultGlobalContainer: { title: 'Engineering', displayUrl: '/spaces/ENG' }, + excerpt: 'release', + url: '/spaces/ENG/pages/98765', + }, + ], + totalSize: 1, + _links: {}, + }) + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await executeConfluenceSearch( + { ...CONNECTION, query: 'release', limit: 25 }, + { headers: new Headers(), requestId: 'request-1' } + ) + + expect(result.results[0]).toMatchObject({ + spaceKey: 'ENG', + space: { id: '4242', key: 'ENG', name: 'Engineering' }, + }) + }) + + it('keeps a trailing CQL order by after the parenthesized filter', async () => { + const fetchMock = vi.fn(async () => + Response.json({ results: [], totalSize: 0, _links: {} }) + ) + vi.stubGlobal('fetch', fetchMock) + + await executeConfluenceSearch( + { + ...CONNECTION, + query: 'release', + limit: 25, + cql: 'title ~ "sort order by x" ORDER BY lastmodified desc', + }, + { headers: new Headers(), requestId: 'request-1' } + ) + + const sentCql = new URL(String(fetchMock.mock.calls[0]?.[0])).searchParams.get('cql') + expect(sentCql).toBe( + 'text ~ "release" AND (title ~ "sort order by x") ORDER BY lastmodified desc' + ) + }) + + it.each([ + [ + 'search', + () => + executeConfluenceSearch( + { ...CONNECTION, query: 'release', limit: 25, cursor: 'cursor-2' }, + { headers: new Headers(), requestId: 'request-1' } + ), + ], + [ + 'search in space', + () => + executeConfluenceSearchInSpace( + { ...CONNECTION, spaceKey: 'ENG', limit: 25, cursor: 'cursor-2' }, + { headers: new Headers(), requestId: 'request-1' } + ), + ], + ])('sends next=true with the cursor on %s, as Confluence next links do', async (_, run) => { + const fetchMock = vi.fn(async () => + Response.json({ results: [], totalSize: 0, _links: {} }) + ) + vi.stubGlobal('fetch', fetchMock) + + await run() + + const params = new URL(String(fetchMock.mock.calls.at(-1)?.[0])).searchParams + expect(params.get('cursor')).toBe('cursor-2') + expect(params.get('next')).toBe('true') + }) + it('fails closed when stored-file authorization denies access', async () => { mockAssertToolFileAccess.mockResolvedValueOnce(Response.json({ error: 'Forbidden' })) diff --git a/apps/sim/lib/internal/confluence/operations.ts b/apps/sim/lib/internal/confluence/operations.ts index 14f75ba33d7..9a9f710c479 100644 --- a/apps/sim/lib/internal/confluence/operations.ts +++ b/apps/sim/lib/internal/confluence/operations.ts @@ -116,12 +116,20 @@ function mappedPage(value: unknown): JsonObject { spaceId: page.spaceId ?? null, parentId: page.parentId ?? null, authorId: page.authorId ?? null, + ownerId: page.ownerId ?? null, createdAt: page.createdAt ?? null, version: page.version ?? null, webUrl: nested(page, '_links', 'webui') ?? null, } } +function mappedLabels(value: unknown): JsonObject[] { + return toArray(toRecord(value).results).map((item) => { + const label = toRecord(item) + return { id: label.id, name: label.name, prefix: label.prefix || 'global' } + }) +} + const NUMERIC_SPACE_ID_PATTERN = /^[1-9][0-9]{0,19}$/ const SPACE_STATUSES = ['current', 'archived'] as const @@ -193,7 +201,7 @@ export async function executeConfluenceRetrievePage( ) { const client = await createConfluenceClient(input, context.signal) const data = await client.json( - client.apiV2(`/pages/${input.pageId}?body-format=storage`), + client.apiV2(`/pages/${input.pageId}?body-format=storage&include-labels=true`), {}, context.signal ) @@ -213,6 +221,10 @@ export async function executeConfluenceRetrievePage( createdAt: data.createdAt ?? null, version: data.version ?? null, _links: data._links ?? null, + ownerId: data.ownerId ?? null, + lastOwnerId: data.lastOwnerId ?? null, + parentType: data.parentType ?? null, + labels: mappedLabels(data.labels), } } @@ -323,7 +335,9 @@ export async function executeConfluenceListAttachments( mediaType: attachment.mediaType || '', downloadUrl: attachment.downloadLink || nested(attachment, '_links', 'download') || '', status: attachment.status ?? null, - webuiUrl: nested(attachment, '_links', 'webui') ?? null, + webuiUrl: nested(attachment, '_links', 'webui') ?? attachment.webuiLink ?? null, + createdAt: attachment.createdAt ?? null, + fileId: attachment.fileId ?? null, pageId: attachment.pageId ?? null, blogPostId: attachment.blogPostId ?? null, comment: attachment.comment ?? null, @@ -488,16 +502,34 @@ export async function executeConfluenceCreateComment( context: ConfluenceOperationContext ) { assertId(input.pageId, 'pageId') - const client = await createConfluenceClient(input, context.signal) + if (!input.parentCommentId) { + const client = await createConfluenceClient(input, context.signal) + const data = await client.json( + client.apiV2('/footer-comments'), + jsonInit('POST', { + pageId: input.pageId, + body: { representation: 'storage', value: input.comment }, + }), + context.signal + ) + return { ...data, pageId: input.pageId } + } + assertId(input.parentCommentId, 'parentCommentId') + const { client, endpoint, response } = await detectCommentEndpoint( + { ...input, commentId: input.parentCommentId }, + context + ) + if (!response.ok) await throwConfluenceResponseError(response, context.signal) + await readConfluenceResponseText(response, context.signal, 'Confluence comment lookup response') const data = await client.json( - client.apiV2('/footer-comments'), + client.apiV2(`/${endpoint}`), jsonInit('POST', { - pageId: input.pageId, + parentCommentId: input.parentCommentId, body: { representation: 'storage', value: input.comment }, }), context.signal ) - return { ...data, pageId: input.pageId } + return { ...data, pageId: data.pageId ?? input.pageId } } export async function executeConfluenceListComments( @@ -511,31 +543,40 @@ export async function executeConfluenceListComments( 'body-format': input.bodyFormat, }) if (input.cursor) query.set('cursor', input.cursor) + const commentType = input.commentType === 'inline' ? 'inline' : 'footer' const data = await client.json( - client.apiV2(`/pages/${input.pageId}/footer-comments?${query}`), + client.apiV2(`/pages/${input.pageId}/${commentType}-comments?${query}`), {}, context.signal ) return { comments: toArray(data.results).map((value) => { const comment = toRecord(value) + const body = toRecord(comment.body) return { id: comment.id, body: { value: - nested(comment, 'body', 'storage', 'value') || - nested(comment, 'body', 'view', 'value') || + nested(body, input.bodyFormat, 'value') || + nested(body, 'storage', 'value') || + nested(body, 'atlas_doc_format', 'value') || + nested(body, 'view', 'value') || '', representation: input.bodyFormat, }, - createdAt: comment.createdAt || '', - authorId: comment.authorId || '', + createdAt: comment.createdAt || nested(comment, 'version', 'createdAt') || '', + authorId: comment.authorId || nested(comment, 'version', 'authorId') || '', status: comment.status ?? null, title: comment.title ?? null, pageId: comment.pageId ?? null, blogPostId: comment.blogPostId ?? null, parentCommentId: comment.parentCommentId ?? null, version: comment.version ?? null, + commentType, + resolutionStatus: comment.resolutionStatus ?? null, + inlineOriginalSelection: nested(comment, 'properties', 'inlineOriginalSelection') ?? null, + inlineMarkerRef: nested(comment, 'properties', 'inlineMarkerRef') ?? null, + webUrl: nested(comment, '_links', 'webui') ?? null, } }), nextCursor: nextCursor(data), @@ -864,47 +905,102 @@ export async function executeConfluenceGetPagesByLabel( } } +const CQL_ORDER_BY = /\s*\border\s+by\s/iy + +/** + * Splits a trailing `order by` clause off a CQL fragment so the condition can be + * parenthesized while the ordering stays at the end, ignoring quoted values. + */ +function splitCqlOrderBy(cql: string): { condition: string; orderBy: string } { + let quote: string | null = null + let orderByStart = -1 + for (let index = 0; index < cql.length; index++) { + const char = cql[index] + if (char === '"' || char === "'") { + let backslashes = 0 + for (let cursor = index - 1; cursor >= 0 && cql[cursor] === '\\'; cursor--) backslashes++ + if (backslashes % 2 === 0) { + if (quote === null) quote = char + else if (quote === char) quote = null + } + continue + } + if (quote !== null) continue + if (index > 0 && !/\s/.test(cql[index - 1])) continue + CQL_ORDER_BY.lastIndex = index + if (CQL_ORDER_BY.test(cql)) orderByStart = index + } + if (orderByStart === -1) return { condition: cql, orderBy: '' } + return { condition: cql.slice(0, orderByStart), orderBy: ` ${cql.slice(orderByStart).trim()}` } +} + function escapeCql(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') } +/** + * Pages the v1 `/search` endpoint forward. Confluence's own `_links.next` URLs carry + * `next=true` alongside the cursor, so a resumed page sends both. + */ +function setSearchCursor(query: URLSearchParams, cursor: string | undefined) { + if (!cursor) return + query.set('next', 'true') + query.set('cursor', cursor) +} + export async function executeConfluenceSearch( input: ConfluenceSearchBody, context: ConfluenceOperationContext ) { + assertCursor(input.cursor) const client = await createConfluenceClient(input, context.signal) + let cql = `text ~ "${escapeCql(input.query)}"` + const extraCql = input.cql?.trim() + if (extraCql) { + const { condition, orderBy } = splitCqlOrderBy(extraCql) + cql += condition.trim() ? ` AND (${condition.trim()})${orderBy}` : orderBy + } const query = new URLSearchParams({ - cql: `text ~ "${escapeCql(input.query)}"`, + cql, limit: input.limit.toString(), + expand: 'content.space', }) + setSearchCursor(query, input.cursor) const data = await client.json(client.rest(`/search?${query}`), {}, context.signal) - return { - results: toArray(data.results).map((value) => { - const result = toRecord(value) - const content = toRecord(result.content) - const globalContainer = toRecord(result.resultGlobalContainer) - const contentSpace = toRecord(content.space) - const space = Object.keys(globalContainer).length ? globalContainer : contentSpace - return { - id: content.id || result.id, - title: content.title || result.title, - type: content.type || result.type, - url: result.url || nested(result, '_links', 'webui') || '', - excerpt: result.excerpt || '', - status: content.status ?? null, - spaceKey: globalContainer.key ?? contentSpace.key ?? null, - space: Object.keys(space).length + const results = toArray(data.results).map((value) => { + const result = toRecord(value) + const content = toRecord(result.content) + const globalContainer = toRecord(result.resultGlobalContainer) + const contentSpace = toRecord(content.space) + const resultSpace = toRecord(result.space) + const space = Object.keys(contentSpace).length ? contentSpace : resultSpace + const spaceId = space.id ?? null + const spaceName = space.name ?? globalContainer.title ?? null + return { + id: content.id || result.id, + title: content.title || result.title, + type: content.type || result.type, + url: result.url || nested(result, '_links', 'webui') || '', + excerpt: result.excerpt || '', + status: content.status ?? null, + spaceKey: space.key ?? null, + space: + space.key || spaceName ? { - id: space.id ?? null, + id: spaceId === null ? null : String(spaceId), key: space.key ?? null, - name: space.name ?? space.title ?? null, + name: spaceName, } : null, - lastModified: - result.lastModified ?? nested(content, 'history', 'lastUpdated', 'when') ?? null, - entityType: result.entityType ?? null, - } - }), + lastModified: + result.lastModified ?? nested(content, 'history', 'lastUpdated', 'when') ?? null, + entityType: result.entityType ?? null, + } + }) + return { + results, + totalSize: data.totalSize ?? results.length, + nextCursor: nextCursor(data), } } @@ -917,7 +1013,9 @@ export async function executeConfluenceSearchInSpace( let cql = `space = "${escapeCql(spaceKey)}"` if (input.query) cql += ` AND text ~ "${escapeCql(input.query)}"` if (input.contentType) cql += ` AND type = "${escapeCql(input.contentType)}"` + assertCursor(input.cursor) const query = new URLSearchParams({ cql, limit: cappedLimit(input.limit) }) + setSearchCursor(query, input.cursor) const data = await client.json(client.rest(`/search?${query}`), {}, context.signal) const results = toArray(data.results).map((value) => { const result = toRecord(value) @@ -932,7 +1030,12 @@ export async function executeConfluenceSearchInSpace( lastModified: result.lastModified ?? null, } }) - return { results, spaceKey, totalSize: data.totalSize ?? results.length } + return { + results, + spaceKey, + totalSize: data.totalSize ?? results.length, + nextCursor: nextCursor(data), + } } export async function executeConfluenceListBlogPostsInSpace( @@ -1109,9 +1212,10 @@ export async function executeConfluenceGetBlogPost( ) { if (!('blogPostId' in input)) throw new ConfluenceOperationError('Blog post ID is required', 400) const client = await createConfluenceClient(input, context.signal) - const query = input.bodyFormat ? `?body-format=${encodeURIComponent(input.bodyFormat)}` : '' + const query = new URLSearchParams({ 'include-labels': 'true' }) + if (input.bodyFormat) query.set('body-format', input.bodyFormat) const data = await client.json( - client.apiV2(`/blogposts/${input.blogPostId}${query}`), + client.apiV2(`/blogposts/${input.blogPostId}?${query}`), {}, context.signal ) @@ -1125,6 +1229,7 @@ export async function executeConfluenceGetBlogPost( version: data.version ?? null, body: data.body ?? null, webUrl: nested(data, '_links', 'webui') ?? null, + labels: mappedLabels(data.labels), } } @@ -1185,7 +1290,11 @@ export async function executeConfluenceGetSpace( ) { const client = await createConfluenceClient(input, context.signal) const spaceId = await resolveConfluenceSpaceId(client, input.spaceId, context.signal) - return client.json(client.apiV2(`/spaces/${spaceId}`), {}, context.signal) + return client.json( + client.apiV2(`/spaces/${spaceId}?description-format=plain`), + {}, + context.signal + ) } export async function executeConfluenceCreateSpace( @@ -1262,7 +1371,10 @@ export async function executeConfluenceListSpaces( context: ConfluenceOperationContext ) { const client = await createConfluenceClient(input, context.signal) - const query = new URLSearchParams({ limit: cappedLimit(input.limit) }) + const query = new URLSearchParams({ + limit: cappedLimit(input.limit), + 'description-format': 'plain', + }) if (input.cursor) query.set('cursor', input.cursor) const data = await client.json(client.apiV2(`/spaces?${query}`), {}, context.signal) return { @@ -1277,7 +1389,9 @@ export async function executeConfluenceListSpaces( authorId: space.authorId ?? null, createdAt: space.createdAt ?? null, homepageId: space.homepageId ?? null, - description: space.description ?? null, + description: nested(space, 'description', 'plain') ?? null, + spaceOwnerId: space.spaceOwnerId ?? null, + webUrl: nested(space, '_links', 'webui') ?? null, } }), nextCursor: nextCursor(data), diff --git a/apps/sim/lib/internal/crowdstrike/client.ts b/apps/sim/lib/internal/crowdstrike/client.ts index 92c4974d3ee..4ab2896711e 100644 --- a/apps/sim/lib/internal/crowdstrike/client.ts +++ b/apps/sim/lib/internal/crowdstrike/client.ts @@ -2,6 +2,7 @@ import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import { isRecordLike, toRecordOrNull } from '@sim/utils/object' import { MAX_JSON_API_RESPONSE_BYTES } from '@/lib/core/security/input-validation.server' import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' +import { throttledRetryAfterMs } from '@/lib/credentials/token-service-accounts/errors' import type { CrowdStrikeCloud, CrowdStrikeInlineCredentials } from '@/tools/crowdstrike/types' export type JsonRecord = Record @@ -160,7 +161,12 @@ export function getFalconErrorMessage(data: unknown, fallback: string): string { export class CrowdStrikeAuthError extends Error { readonly status: number - constructor(message: string, status: number) { + constructor( + message: string, + status: number, + /** The token endpoint's `Retry-After` on a 429 or 503, in milliseconds. */ + readonly retryAfterMs?: number + ) { super(message) this.name = 'CrowdStrikeAuthError' this.status = status >= 400 && status <= 599 ? status : 502 @@ -220,7 +226,8 @@ export class CrowdStrikeTokenRedirectError extends Error { * With `followRedirects: false` a redirect is never followed and throws * {@link CrowdStrikeTokenRedirectError}, so the secret reaches no other host; * the legacy block's inline path keeps following one as it always has. A - * refusal throws {@link CrowdStrikeAuthError} with Falcon's status. + * refusal throws {@link CrowdStrikeAuthError} with Falcon's status and + * `Retry-After`, even when its error body cannot be read. * * @see https://developer.crowdstrike.com/api-reference/collections/oauth2.md */ @@ -258,12 +265,15 @@ export async function requestCrowdStrikeToken( ) } - const data = await readFalconJson(response, signal) + const data = response.ok + ? await readFalconJson(response, signal) + : await readFalconJson(response, signal).catch(() => null) signal?.throwIfAborted() if (!response.ok) { throw new CrowdStrikeAuthError( getFalconErrorMessage(data, 'Failed to authenticate with CrowdStrike'), - response.status + response.status, + throttledRetryAfterMs(response) ) } if (!isRecordLike(data) || typeof data.access_token !== 'string' || !data.access_token.trim()) { diff --git a/apps/sim/lib/internal/dynamodb/client.test.ts b/apps/sim/lib/internal/dynamodb/client.test.ts new file mode 100644 index 00000000000..90b696c242a --- /dev/null +++ b/apps/sim/lib/internal/dynamodb/client.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { createDynamoDBClient, queryItems } from '@/lib/internal/dynamodb/client' + +function clientReturning(output: Record) { + const client = createDynamoDBClient({ + region: 'us-east-1', + accessKeyId: 'AKIAEXAMPLE', + secretAccessKey: 'secret', + }) + client.middlewareStack.addRelativeTo( + () => async () => ({ output: { $metadata: {}, ...output }, response: {} }), + { relation: 'after', toMiddleware: 'DocumentUnmarshall' } + ) + return client +} + +/** A client that records the marshalled request input DynamoDB would receive. */ +function clientCapturing(onInput: (input: Record) => void) { + const client = createDynamoDBClient({ + region: 'us-east-1', + accessKeyId: 'AKIAEXAMPLE', + secretAccessKey: 'secret', + }) + client.middlewareStack.add( + () => async (args) => { + onInput(args.input as Record) + return { output: { $metadata: {}, Items: [], Count: 0 }, response: {} } + }, + { step: 'build' } + ) + return client +} + +describe('DynamoDB item unmarshalling', () => { + it('returns JSON-serializable items for sets, binary and integers beyond 2^53', async () => { + const client = clientReturning({ + Count: 1, + ScannedCount: 3, + Items: [ + { + pk: { S: 'USER#1' }, + tags: { SS: ['a', 'b'] }, + scores: { NS: ['1', '2'] }, + avatar: { B: new Uint8Array([104, 105]) }, + snowflake: { N: '1234567890123456789' }, + age: { N: '42' }, + ratio: { N: '0.12345678901234567890123' }, + }, + ], + }) + + const result = await queryItems(client, 'Users', 'pk = :pk', { + expressionAttributeValues: { ':pk': 'USER#1' }, + }) + + expect(await Response.json(result).json()).toEqual({ + items: [ + { + pk: 'USER#1', + tags: ['a', 'b'], + scores: [1, 2], + avatar: 'aGk=', + snowflake: '1234567890123456789', + age: 42, + ratio: 0.12345678901234568, + }, + ], + count: 1, + scannedCount: 3, + }) + client.destroy() + }) + + it('round-trips large-number and binary keys through the pagination cursor', async () => { + const pageClient = clientReturning({ + Count: 0, + Items: [], + LastEvaluatedKey: { + pk: { S: 'USER#1' }, + sk: { N: '1234567890123456789' }, + bin: { B: new Uint8Array([104, 105]) }, + age: { N: '42' }, + ratio: { N: '0.12345678901234567890123' }, + nearOne: { N: '1.0000000000000000001' }, + }, + }) + const firstPage = await queryItems(pageClient, 'Users', 'pk = :pk', { + expressionAttributeValues: { ':pk': 'USER#1' }, + }) + const cursor = await Response.json(firstPage.lastEvaluatedKey).json() + expect(cursor).toEqual({ + pk: 'USER#1', + sk: { N: '1234567890123456789' }, + bin: { B: 'aGk=' }, + age: 42, + ratio: { N: '0.12345678901234567890123' }, + nearOne: { N: '1.0000000000000000001' }, + }) + + let sentStartKey: unknown + const client = clientCapturing((input) => { + sentStartKey = input.ExclusiveStartKey + }) + await queryItems(client, 'Users', 'pk = :pk', { + expressionAttributeValues: { ':pk': 'USER#1' }, + exclusiveStartKey: cursor, + }) + + expect(sentStartKey).toEqual({ + pk: { S: 'USER#1' }, + sk: { N: '1234567890123456789' }, + bin: { B: new Uint8Array([104, 105]) }, + age: { N: '42' }, + ratio: { N: '0.12345678901234567890123' }, + nearOne: { N: '1.0000000000000000001' }, + }) + pageClient.destroy() + client.destroy() + }) +}) diff --git a/apps/sim/lib/internal/dynamodb/client.ts b/apps/sim/lib/internal/dynamodb/client.ts index 71bcb38bfcc..b8c62316993 100644 --- a/apps/sim/lib/internal/dynamodb/client.ts +++ b/apps/sim/lib/internal/dynamodb/client.ts @@ -3,14 +3,103 @@ import { DeleteCommand, DynamoDBDocumentClient, GetCommand, + NumberValue, PutCommand, QueryCommand, ScanCommand, UpdateCommand, } from '@aws-sdk/lib-dynamodb' +import { isRecordLike } from '@sim/utils/object' import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { DynamoDBConnectionConfig, DynamoDBTableSchema } from '@/tools/dynamodb/types' +type DynamoDBReturnValues = 'NONE' | 'ALL_OLD' | 'UPDATED_OLD' | 'ALL_NEW' | 'UPDATED_NEW' + +/** + * Keeps any DynamoDB number a JS number cannot hold exactly (integers beyond + * `Number.MAX_SAFE_INTEGER`, high-precision decimals, non-finite values) as a `NumberValue` + * with the exact string, so pagination cursors can round-trip it. The SDK default would turn + * large integers into a `BigInt` that the JSON response cannot serialize. + */ +function unwrapNumber(value: string): number | NumberValue { + const parsed = Number(value) + const exact = Number.isFinite(parsed) && String(parsed) === value + if (exact && (!Number.isInteger(parsed) || Number.isSafeInteger(parsed))) return parsed + return NumberValue.from(value) +} + +/** + * Item output for a number kept as a `NumberValue`: the exact string for integers beyond + * `Number.MAX_SAFE_INTEGER` and non-finite values, otherwise the nearest JS number. + */ +function numberValueToJson(value: NumberValue): number | string { + const exact = value.toString() + const parsed = Number(exact) + if (!Number.isFinite(parsed) || (Number.isInteger(parsed) && !Number.isSafeInteger(parsed))) { + return exact + } + return parsed +} + +/** + * Converts unmarshalled DynamoDB values into JSON-serializable shapes: string, number and + * binary sets become arrays, binary values become base64 strings, and numbers kept as a + * `NumberValue` become a JS number or, when one cannot hold them, their exact string. Without this a set serializes as `{}` and a binary + * value as an object of byte indexes. + */ +function toJsonSafe(value: unknown): unknown { + if (value instanceof NumberValue) return numberValueToJson(value) + if (value instanceof Set) return Array.from(value, toJsonSafe) + if (value instanceof Uint8Array) return Buffer.from(value).toString('base64') + if (Array.isArray(value)) return value.map(toJsonSafe) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, toJsonSafe(entry)])) + } + return value +} + +function toJsonSafeRecord( + value: Record | undefined +): Record | undefined { + return value ? (toJsonSafe(value) as Record) : undefined +} + +/** + * Encodes a `LastEvaluatedKey` so it can be passed back as `exclusiveStartKey`. Key + * attributes are always scalar (S, N or B), so strings and exact numbers stay as-is while a + * number a JS number cannot hold exactly becomes `{ N }` and a binary key becomes `{ B }` (base64): + * plain strings would otherwise be sent back as S and fail the key type check. + */ +function toCursor(key: Record | undefined): Record | undefined { + if (!key) return undefined + return Object.fromEntries( + Object.entries(key).map(([name, value]) => { + if (value instanceof NumberValue) return [name, { N: value.toString() }] + if (value instanceof Uint8Array) return [name, { B: toJsonSafe(value) }] + return [name, value] + }) + ) +} + +const DYNAMODB_NUMBER = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/ + +/** Restores the `{ N }` and `{ B }` cursor values written by {@link toCursor}. */ +function fromCursor(key: Record): Record { + return Object.fromEntries( + Object.entries(key).map(([name, value]) => { + if (isRecordLike(value) && Object.keys(value).length === 1) { + if (typeof value.N === 'string' && DYNAMODB_NUMBER.test(value.N)) { + return [name, NumberValue.from(value.N)] + } + if (typeof value.B === 'string') { + return [name, new Uint8Array(Buffer.from(value.B, 'base64'))] + } + } + return [name, value] + }) + ) +} + export function createDynamoDBClient(config: DynamoDBConnectionConfig): DynamoDBDocumentClient { const client = new DynamoDBClient({ ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), @@ -22,7 +111,7 @@ export function createDynamoDBClient(config: DynamoDBConnectionConfig): DynamoDB convertEmptyValues: false, }, unmarshallOptions: { - wrapNumbers: false, + wrapNumbers: unwrapNumber, }, }) } @@ -32,17 +121,25 @@ export async function getItem( tableName: string, key: Record, consistentRead?: boolean, - signal?: AbortSignal + signal?: AbortSignal, + options?: { + projectionExpression?: string + expressionAttributeNames?: Record + } ): Promise<{ item: Record | null }> { const command = new GetCommand({ TableName: tableName, Key: key, ConsistentRead: consistentRead, + ...(options?.projectionExpression && { ProjectionExpression: options.projectionExpression }), + ...(options?.expressionAttributeNames && { + ExpressionAttributeNames: options.expressionAttributeNames, + }), }) const response = await client.send(command, { abortSignal: signal }) return { - item: (response.Item as Record) || null, + item: toJsonSafeRecord(response.Item) ?? null, } } @@ -54,9 +151,10 @@ export async function putItem( conditionExpression?: string expressionAttributeNames?: Record expressionAttributeValues?: Record + returnValues?: 'NONE' | 'ALL_OLD' }, signal?: AbortSignal -): Promise<{ success: boolean }> { +): Promise<{ success: boolean; attributes: Record | null }> { const command = new PutCommand({ TableName: tableName, Item: item, @@ -67,10 +165,11 @@ export async function putItem( ...(options?.expressionAttributeValues && { ExpressionAttributeValues: options.expressionAttributeValues, }), + ...(options?.returnValues && { ReturnValues: options.returnValues }), }) - await client.send(command, { abortSignal: signal }) - return { success: true } + const response = await client.send(command, { abortSignal: signal }) + return { success: true, attributes: toJsonSafeRecord(response.Attributes) ?? null } } export async function queryItems( @@ -85,11 +184,14 @@ export async function queryItems( limit?: number exclusiveStartKey?: Record scanIndexForward?: boolean + projectionExpression?: string + consistentRead?: boolean }, signal?: AbortSignal ): Promise<{ items: Record[] count: number + scannedCount: number | null lastEvaluatedKey?: Record }> { const command = new QueryCommand({ @@ -104,15 +206,18 @@ export async function queryItems( }), ...(options?.indexName && { IndexName: options.indexName }), ...(options?.limit && { Limit: options.limit }), - ...(options?.exclusiveStartKey && { ExclusiveStartKey: options.exclusiveStartKey }), + ...(options?.exclusiveStartKey && { ExclusiveStartKey: fromCursor(options.exclusiveStartKey) }), ...(options?.scanIndexForward !== undefined && { ScanIndexForward: options.scanIndexForward }), + ...(options?.projectionExpression && { ProjectionExpression: options.projectionExpression }), + ...(options?.consistentRead !== undefined && { ConsistentRead: options.consistentRead }), }) const response = await client.send(command, { abortSignal: signal }) return { - items: (response.Items as Record[]) || [], + items: (response.Items ?? []).map((item) => toJsonSafeRecord(item) ?? {}), count: response.Count || 0, - lastEvaluatedKey: response.LastEvaluatedKey as Record | undefined, + scannedCount: response.ScannedCount ?? null, + lastEvaluatedKey: toCursor(response.LastEvaluatedKey), } } @@ -126,11 +231,16 @@ export async function scanItems( expressionAttributeValues?: Record limit?: number exclusiveStartKey?: Record + indexName?: string + consistentRead?: boolean + segment?: number + totalSegments?: number }, signal?: AbortSignal ): Promise<{ items: Record[] count: number + scannedCount: number | null lastEvaluatedKey?: Record }> { const command = new ScanCommand({ @@ -144,14 +254,19 @@ export async function scanItems( ExpressionAttributeValues: options.expressionAttributeValues, }), ...(options?.limit && { Limit: options.limit }), - ...(options?.exclusiveStartKey && { ExclusiveStartKey: options.exclusiveStartKey }), + ...(options?.exclusiveStartKey && { ExclusiveStartKey: fromCursor(options.exclusiveStartKey) }), + ...(options?.indexName && { IndexName: options.indexName }), + ...(options?.consistentRead !== undefined && { ConsistentRead: options.consistentRead }), + ...(options?.segment !== undefined && { Segment: options.segment }), + ...(options?.totalSegments !== undefined && { TotalSegments: options.totalSegments }), }) const response = await client.send(command, { abortSignal: signal }) return { - items: (response.Items as Record[]) || [], + items: (response.Items ?? []).map((item) => toJsonSafeRecord(item) ?? {}), count: response.Count || 0, - lastEvaluatedKey: response.LastEvaluatedKey as Record | undefined, + scannedCount: response.ScannedCount ?? null, + lastEvaluatedKey: toCursor(response.LastEvaluatedKey), } } @@ -164,6 +279,7 @@ export async function updateItem( expressionAttributeNames?: Record expressionAttributeValues?: Record conditionExpression?: string + returnValues?: DynamoDBReturnValues }, signal?: AbortSignal ): Promise<{ attributes: Record | null }> { @@ -178,12 +294,12 @@ export async function updateItem( ExpressionAttributeValues: options.expressionAttributeValues, }), ...(options?.conditionExpression && { ConditionExpression: options.conditionExpression }), - ReturnValues: 'ALL_NEW', + ReturnValues: options?.returnValues ?? 'ALL_NEW', }) const response = await client.send(command, { abortSignal: signal }) return { - attributes: (response.Attributes as Record) || null, + attributes: toJsonSafeRecord(response.Attributes) ?? null, } } @@ -195,9 +311,10 @@ export async function deleteItem( conditionExpression?: string expressionAttributeNames?: Record expressionAttributeValues?: Record + returnValues?: 'NONE' | 'ALL_OLD' }, signal?: AbortSignal -): Promise<{ success: boolean }> { +): Promise<{ success: boolean; attributes: Record | null }> { const command = new DeleteCommand({ TableName: tableName, Key: key, @@ -208,10 +325,11 @@ export async function deleteItem( ...(options?.expressionAttributeValues && { ExpressionAttributeValues: options.expressionAttributeValues, }), + ...(options?.returnValues && { ReturnValues: options.returnValues }), }) - await client.send(command, { abortSignal: signal }) - return { success: true } + const response = await client.send(command, { abortSignal: signal }) + return { success: true, attributes: toJsonSafeRecord(response.Attributes) ?? null } } /** @@ -305,6 +423,12 @@ export async function describeTable( itemCount: Number(table.ItemCount) || 0, tableSizeBytes: Number(table.TableSizeBytes) || 0, billingMode: table.BillingModeSummary?.BillingMode || 'PROVISIONED', + tableArn: table.TableArn ?? null, + creationDateTime: table.CreationDateTime?.toISOString() ?? null, + deletionProtectionEnabled: table.DeletionProtectionEnabled ?? null, + streamEnabled: table.StreamSpecification?.StreamEnabled ?? null, + streamViewType: table.StreamSpecification?.StreamViewType ?? null, + latestStreamArn: table.LatestStreamArn ?? null, } return { tableDetails } diff --git a/apps/sim/lib/internal/dynamodb/operations.ts b/apps/sim/lib/internal/dynamodb/operations.ts index 58fcd8e95fb..fcd51419d79 100644 --- a/apps/sim/lib/internal/dynamodb/operations.ts +++ b/apps/sim/lib/internal/dynamodb/operations.ts @@ -22,7 +22,10 @@ export async function executeDynamodbGet(input: AwsDynamodbGetBody, signal?: Abo signal?.throwIfAborted() const client = createDynamoDBClient(input) try { - const result = await getItem(client, input.tableName, input.key, input.consistentRead, signal) + const result = await getItem(client, input.tableName, input.key, input.consistentRead, signal, { + projectionExpression: input.projectionExpression, + expressionAttributeNames: input.expressionAttributeNames, + }) signal?.throwIfAborted() return { message: result.item ? 'Item retrieved successfully' : 'Item not found', @@ -37,7 +40,7 @@ export async function executeDynamodbPut(input: AwsDynamodbPutBody, signal?: Abo signal?.throwIfAborted() const client = createDynamoDBClient(input) try { - await putItem( + const result = await putItem( client, input.tableName, input.item, @@ -45,11 +48,16 @@ export async function executeDynamodbPut(input: AwsDynamodbPutBody, signal?: Abo conditionExpression: input.conditionExpression, expressionAttributeNames: input.expressionAttributeNames, expressionAttributeValues: input.expressionAttributeValues, + returnValues: input.returnValues, }, signal ) signal?.throwIfAborted() - return { message: 'Item created successfully', item: input.item } + return { + message: 'Item created successfully', + item: input.item, + ...(input.returnValues === 'ALL_OLD' && { attributes: result.attributes }), + } } finally { client.destroy() } @@ -71,6 +79,8 @@ export async function executeDynamodbQuery(input: AwsDynamodbQueryBody, signal?: limit: input.limit, exclusiveStartKey: input.exclusiveStartKey, scanIndexForward: input.scanIndexForward, + projectionExpression: input.projectionExpression, + consistentRead: input.consistentRead, }, signal ) @@ -79,6 +89,7 @@ export async function executeDynamodbQuery(input: AwsDynamodbQueryBody, signal?: message: `Query returned ${result.count} items`, items: result.items, count: result.count, + scannedCount: result.scannedCount, ...(result.lastEvaluatedKey && { lastEvaluatedKey: result.lastEvaluatedKey }), } } finally { @@ -100,6 +111,10 @@ export async function executeDynamodbScan(input: AwsDynamodbScanBody, signal?: A expressionAttributeValues: input.expressionAttributeValues, limit: input.limit, exclusiveStartKey: input.exclusiveStartKey, + indexName: input.indexName, + consistentRead: input.consistentRead, + segment: input.segment, + totalSegments: input.totalSegments, }, signal ) @@ -108,6 +123,7 @@ export async function executeDynamodbScan(input: AwsDynamodbScanBody, signal?: A message: `Scan returned ${result.count} items`, items: result.items, count: result.count, + scannedCount: result.scannedCount, ...(result.lastEvaluatedKey && { lastEvaluatedKey: result.lastEvaluatedKey }), } } finally { @@ -128,6 +144,7 @@ export async function executeDynamodbUpdate(input: AwsDynamodbUpdateBody, signal expressionAttributeNames: input.expressionAttributeNames, expressionAttributeValues: input.expressionAttributeValues, conditionExpression: input.conditionExpression, + returnValues: input.returnValues, }, signal ) @@ -142,7 +159,7 @@ export async function executeDynamodbDelete(input: AwsDynamodbDeleteBody, signal signal?.throwIfAborted() const client = createDynamoDBClient(input) try { - await deleteItem( + const result = await deleteItem( client, input.tableName, input.key, @@ -150,11 +167,15 @@ export async function executeDynamodbDelete(input: AwsDynamodbDeleteBody, signal conditionExpression: input.conditionExpression, expressionAttributeNames: input.expressionAttributeNames, expressionAttributeValues: input.expressionAttributeValues, + returnValues: input.returnValues, }, signal ) signal?.throwIfAborted() - return { message: 'Item deleted successfully' } + return { + message: 'Item deleted successfully', + ...(input.returnValues === 'ALL_OLD' && { attributes: result.attributes }), + } } finally { client.destroy() } diff --git a/apps/sim/lib/internal/ec2/operations.ts b/apps/sim/lib/internal/ec2/operations.ts index 13e72a1141c..1262e6b32ab 100644 --- a/apps/sim/lib/internal/ec2/operations.ts +++ b/apps/sim/lib/internal/ec2/operations.ts @@ -178,7 +178,12 @@ export async function executeEc2DescribeInstances( instanceId: instance.InstanceId ?? '', instanceType: instance.InstanceType ?? null, state: mapStateName(instance.State), + stateCode: instance.State?.Code ?? null, stateReason: instance.StateReason?.Message ?? null, + stateReasonCode: instance.StateReason?.Code ?? null, + stateTransitionReason: instance.StateTransitionReason || null, + reservationId: reservation.ReservationId ?? null, + ownerId: reservation.OwnerId ?? null, launchTime: toIsoString(instance.LaunchTime), availabilityZone: instance.Placement?.AvailabilityZone ?? null, vpcId: instance.VpcId ?? null, @@ -190,9 +195,36 @@ export async function executeEc2DescribeInstances( imageId: instance.ImageId ?? null, keyName: instance.KeyName ?? null, platformDetails: instance.PlatformDetails ?? null, + platform: instance.Platform ?? null, architecture: instance.Architecture ?? null, iamInstanceProfileArn: instance.IamInstanceProfile?.Arn ?? null, + ipv6Address: instance.Ipv6Address ?? null, + tenancy: instance.Placement?.Tenancy ?? null, + instanceLifecycle: instance.InstanceLifecycle ?? null, + spotInstanceRequestId: instance.SpotInstanceRequestId ?? null, + monitoringState: instance.Monitoring?.State ?? null, + ebsOptimized: instance.EbsOptimized ?? null, + rootDeviceName: instance.RootDeviceName ?? null, + rootDeviceType: instance.RootDeviceType ?? null, + metadataHttpTokens: instance.MetadataOptions?.HttpTokens ?? null, securityGroups: mapGroups(instance.SecurityGroups), + blockDeviceMappings: (instance.BlockDeviceMappings ?? []).map((mapping) => ({ + deviceName: mapping.DeviceName ?? null, + volumeId: mapping.Ebs?.VolumeId ?? null, + status: mapping.Ebs?.Status ?? null, + attachTime: toIsoString(mapping.Ebs?.AttachTime), + deleteOnTermination: mapping.Ebs?.DeleteOnTermination ?? null, + })), + networkInterfaces: (instance.NetworkInterfaces ?? []).map((eni) => ({ + networkInterfaceId: eni.NetworkInterfaceId ?? null, + subnetId: eni.SubnetId ?? null, + vpcId: eni.VpcId ?? null, + privateIpAddress: eni.PrivateIpAddress ?? null, + publicIp: eni.Association?.PublicIp ?? null, + macAddress: eni.MacAddress ?? null, + status: eni.Status ?? null, + deviceIndex: eni.Attachment?.DeviceIndex ?? null, + })), tags: tagsToRecord(instance.Tags), })) ) diff --git a/apps/sim/lib/internal/gmail/mail.ts b/apps/sim/lib/internal/gmail/mail.ts index 323bcffe417..beda80d6af9 100644 --- a/apps/sim/lib/internal/gmail/mail.ts +++ b/apps/sim/lib/internal/gmail/mail.ts @@ -112,6 +112,7 @@ async function buildRawMessage( to: input.to, cc: input.cc ?? undefined, bcc: input.bcc ?? undefined, + replyTo: input.replyTo ?? undefined, subject: input.subject || threading.subject || '', body: input.body, contentType: input.contentType || 'text', @@ -129,6 +130,7 @@ async function buildRawMessage( to: input.to, cc: input.cc, bcc: input.bcc, + replyTo: input.replyTo, subject: input.subject || threading.subject, body: input.body, contentType: input.contentType || 'text', diff --git a/apps/sim/lib/internal/gmail/operations.test.ts b/apps/sim/lib/internal/gmail/operations.test.ts index f9a668115fe..1624eb54a44 100644 --- a/apps/sim/lib/internal/gmail/operations.test.ts +++ b/apps/sim/lib/internal/gmail/operations.test.ts @@ -20,6 +20,7 @@ const { mockProcessFilesToUserFiles } = fileUtilsMockFns const { mockDownloadServableFilesWithinBudget } = fileUtilsServerMockFns import { GmailOperationError } from '@/lib/internal/gmail/errors' +import { executeGmailTool } from '@/lib/internal/gmail/execute-tool' import { executeGmailSend } from '@/lib/internal/gmail/mail' const MAIL = { @@ -65,4 +66,44 @@ describe('Gmail operations', () => { expect(mockDownloadServableFilesWithinBudget).not.toHaveBeenCalled() expect(fetchMock).not.toHaveBeenCalled() }) + + describe('Reply-To header', () => { + const cases = [ + { toolId: 'gmail_send_v2', input: {} }, + { toolId: 'gmail_draft_v2', input: {} }, + { toolId: 'gmail_edit_draft_v2', input: { draftId: 'draft-1' } }, + ] + + async function rawHeaderLines(toolId: string, input: Record) { + const fetchMock = vi.fn(async () => Response.json({ id: 'x', message: { id: 'm' } })) + vi.stubGlobal('fetch', fetchMock) + const response = await executeGmailTool({ + toolId, + input: { ...MAIL, ...input }, + headers: new Headers(), + context: { workflowId: 'wf', userId: 'user-1' }, + requestId: 'request-1', + }) + expect(response.status).toBe(200) + const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const body = JSON.parse(String(call[1].body)) as { raw?: string; message?: { raw: string } } + const raw = body.raw ?? body.message?.raw ?? '' + const mime = Buffer.from(raw, 'base64url').toString('utf-8') + return mime.slice(0, mime.indexOf('\n\n')).split('\n') + } + + it.each(cases)('emits a sanitized Reply-To through $toolId', async ({ toolId, input }) => { + const lines = await rawHeaderLines(toolId, { + ...input, + replyTo: 'support@example.com\r\nBcc: evil@example.com', + }) + expect(lines).toContain('Reply-To: support@example.com Bcc: evil@example.com') + expect(lines.some((line) => line.startsWith('Bcc:'))).toBe(false) + }) + + it.each(cases)('omits Reply-To when unset in $toolId', async ({ toolId, input }) => { + const lines = await rawHeaderLines(toolId, input) + expect(lines.some((line) => line.startsWith('Reply-To:'))).toBe(false) + }) + }) }) diff --git a/apps/sim/lib/internal/iam/client.ts b/apps/sim/lib/internal/iam/client.ts index 8cc18c193bd..c1969d0af6c 100644 --- a/apps/sim/lib/internal/iam/client.ts +++ b/apps/sim/lib/internal/iam/client.ts @@ -5,10 +5,12 @@ import type { Group, Policy, PolicyScopeType, + PolicyUsageType, ResourceSpecificResult, Role, Statement, StatusType, + Tag, User, } from '@aws-sdk/client-iam' import { @@ -47,6 +49,20 @@ export function createIAMClient(config: IAMConnectionConfig): IAMClient { }) } +/** Parses a JSON object string of tag key/value pairs into IAM `Tag` entries. */ +function parseTags(tags?: string | null): Tag[] | undefined { + if (!tags) return undefined + const parsed = JSON.parse(tags) as Record + const entries = Object.entries(parsed) + return entries.length > 0 + ? entries.map(([Key, Value]) => ({ Key, Value: String(Value) })) + : undefined +} + +function mapTags(tags: Tag[] | undefined) { + return tags?.map((t) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [] +} + export async function listUsers( client: IAMClient, pathPrefix?: string | null, @@ -91,7 +107,7 @@ export async function getUser(client: IAMClient, userName?: string | null, signa createDate: user?.CreateDate?.toISOString() ?? null, passwordLastUsed: user?.PasswordLastUsed?.toISOString() ?? null, permissionsBoundaryArn: user?.PermissionsBoundary?.PermissionsBoundaryArn ?? null, - tags: user?.Tags?.map((t) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [], + tags: mapTags(user?.Tags), } } @@ -99,11 +115,16 @@ export async function createUser( client: IAMClient, userName: string, path?: string | null, + permissionsBoundary?: string | null, + tags?: string | null, signal?: AbortSignal ) { + const userTags = parseTags(tags) const command = new CreateUserCommand({ UserName: userName, ...(path ? { Path: path } : {}), + ...(permissionsBoundary ? { PermissionsBoundary: permissionsBoundary } : {}), + ...(userTags ? { Tags: userTags } : {}), }) const response = await client.send(command, { abortSignal: signal }) @@ -115,6 +136,8 @@ export async function createUser( arn: user?.Arn ?? '', path: user?.Path ?? '', createDate: user?.CreateDate?.toISOString() ?? null, + permissionsBoundaryArn: user?.PermissionsBoundary?.PermissionsBoundaryArn ?? null, + tags: mapTags(user?.Tags), } } @@ -180,6 +203,8 @@ export async function getRole(client: IAMClient, roleName: string, signal?: Abor assumeRolePolicyDocument: policyDocument, roleLastUsedDate: role?.RoleLastUsed?.LastUsedDate?.toISOString() ?? null, roleLastUsedRegion: role?.RoleLastUsed?.Region ?? null, + permissionsBoundaryArn: role?.PermissionsBoundary?.PermissionsBoundaryArn ?? null, + tags: mapTags(role?.Tags), } } @@ -190,14 +215,19 @@ export async function createRole( description?: string | null, path?: string | null, maxSessionDuration?: number | null, + permissionsBoundary?: string | null, + tags?: string | null, signal?: AbortSignal ) { + const roleTags = parseTags(tags) const command = new CreateRoleCommand({ RoleName: roleName, AssumeRolePolicyDocument: assumeRolePolicyDocument, ...(description ? { Description: description } : {}), ...(path ? { Path: path } : {}), ...(maxSessionDuration ? { MaxSessionDuration: maxSessionDuration } : {}), + ...(permissionsBoundary ? { PermissionsBoundary: permissionsBoundary } : {}), + ...(roleTags ? { Tags: roleTags } : {}), }) const response = await client.send(command, { abortSignal: signal }) @@ -209,6 +239,8 @@ export async function createRole( arn: role?.Arn ?? '', path: role?.Path ?? '', createDate: role?.CreateDate?.toISOString() ?? null, + permissionsBoundaryArn: role?.PermissionsBoundary?.PermissionsBoundaryArn ?? null, + tags: mapTags(role?.Tags), } } @@ -276,10 +308,12 @@ export async function listPolicies( pathPrefix?: string | null, maxItems?: number | null, marker?: string | null, + policyUsageFilter?: PolicyUsageType | null, signal?: AbortSignal ) { const command = new ListPoliciesCommand({ ...(scope ? { Scope: scope } : {}), + ...(policyUsageFilter ? { PolicyUsageFilter: policyUsageFilter } : {}), ...(onlyAttached != null ? { OnlyAttached: onlyAttached } : {}), ...(pathPrefix ? { PathPrefix: pathPrefix } : {}), ...(maxItems ? { MaxItems: maxItems } : {}), @@ -558,7 +592,7 @@ export async function getPolicy(client: IAMClient, policyArn: string, signal?: A description: policy?.Description ?? null, defaultVersionId: policy?.DefaultVersionId ?? null, permissionsBoundaryUsageCount: policy?.PermissionsBoundaryUsageCount ?? 0, - tags: policy?.Tags?.map((t) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [], + tags: mapTags(policy?.Tags), } } diff --git a/apps/sim/lib/internal/iam/operations.ts b/apps/sim/lib/internal/iam/operations.ts index ae136afd44f..b12a0ef65a1 100644 --- a/apps/sim/lib/internal/iam/operations.ts +++ b/apps/sim/lib/internal/iam/operations.ts @@ -84,7 +84,14 @@ export async function executeIamCreateUser(input: AwsIamCreateUserBody, signal?: return withIamClient( input, async (client) => { - const result = await createUser(client, input.userName, input.path, signal) + const result = await createUser( + client, + input.userName, + input.path, + input.permissionsBoundary, + input.tags, + signal + ) return { message: `User "${result.userName}" created successfully`, ...result } }, signal @@ -125,6 +132,8 @@ export async function executeIamCreateRole(input: AwsIamCreateRoleBody, signal?: input.description, input.path, input.maxSessionDuration, + input.permissionsBoundary, + input.tags, signal ) return { message: `Role "${result.roleName}" created successfully`, ...result } @@ -211,6 +220,7 @@ export async function executeIamListPolicies(input: AwsIamListPoliciesBody, sign input.pathPrefix, input.maxItems, input.marker, + input.policyUsageFilter, signal ), signal diff --git a/apps/sim/lib/internal/identity-center/client.ts b/apps/sim/lib/internal/identity-center/client.ts index ca751a9e29f..24b7d2489ef 100644 --- a/apps/sim/lib/internal/identity-center/client.ts +++ b/apps/sim/lib/internal/identity-center/client.ts @@ -1,6 +1,7 @@ import { DescribeGroupCommand, DescribeUserCommand, + type ExternalId, GetGroupIdCommand, GetUserIdCommand, IdentitystoreClient, @@ -193,9 +194,23 @@ export async function getUserByEmail( userName: describeResponse.UserName ?? '', displayName: describeResponse.DisplayName ?? null, email: primaryEmail, + givenName: describeResponse.Name?.GivenName ?? null, + familyName: describeResponse.Name?.FamilyName ?? null, + userStatus: describeResponse.UserStatus ?? null, + title: describeResponse.Title ?? null, + externalIds: mapExternalIds(describeResponse.ExternalIds), } } +function mapExternalIds(externalIds: ExternalId[] | undefined) { + return ( + externalIds?.map((externalId) => ({ + issuer: externalId.Issuer ?? '', + id: externalId.Id ?? '', + })) ?? [] + ) +} + function mapAssignmentStatus(status: AccountAssignmentOperationStatus) { return { status: status.Status ?? '', @@ -333,6 +348,7 @@ export async function getGroupByDisplayName( groupId, displayName: describeResponse.DisplayName ?? null, description: describeResponse.Description ?? null, + externalIds: mapExternalIds(describeResponse.ExternalIds), } } @@ -361,12 +377,14 @@ export async function listAccountAssignmentsForPrincipal( principalType: 'USER' | 'GROUP', maxResults?: number | null, nextToken?: string | null, + accountId?: string | null, signal?: AbortSignal ) { const command = new ListAccountAssignmentsForPrincipalCommand({ InstanceArn: instanceArn, PrincipalId: principalId, PrincipalType: principalType as PrincipalType, + ...(accountId ? { Filter: { AccountId: accountId } } : {}), ...(maxResults ? { MaxResults: maxResults } : {}), ...(nextToken ? { NextToken: nextToken } : {}), }) @@ -424,6 +442,8 @@ export async function describeUserById( email: primaryEmail, userStatus: response.UserStatus ?? null, title: response.Title ?? null, + givenName: response.Name?.GivenName ?? null, + familyName: response.Name?.FamilyName ?? null, externalIds: response.ExternalIds?.map((externalId) => ({ issuer: externalId.Issuer ?? '', diff --git a/apps/sim/lib/internal/identity-center/operations.ts b/apps/sim/lib/internal/identity-center/operations.ts index 0a1adb7e25d..212d566b6cf 100644 --- a/apps/sim/lib/internal/identity-center/operations.ts +++ b/apps/sim/lib/internal/identity-center/operations.ts @@ -213,6 +213,7 @@ export async function executeIdentityCenterListAccountAssignments( input.principalType, input.maxResults, input.nextToken, + input.accountId, signal ) } finally { diff --git a/apps/sim/lib/internal/jira/input.ts b/apps/sim/lib/internal/jira/input.ts index 2ce970de9e8..a38725d4b0d 100644 --- a/apps/sim/lib/internal/jira/input.ts +++ b/apps/sim/lib/internal/jira/input.ts @@ -26,6 +26,7 @@ export const jiraWriteInputSchema = z.object({ customFieldValue: z.string().optional(), components: z.array(z.string()).optional(), fixVersions: z.array(z.string()).optional(), + customFields: z.record(z.string(), z.unknown()).optional(), }) export const jiraUpdateInputSchema = z.object({ @@ -45,6 +46,8 @@ export const jiraUpdateInputSchema = z.object({ customFieldValue: z.string().optional(), notifyUsers: z.boolean().optional(), cloudId: z.string().optional(), + parent: jiraParentReferenceSchema.optional(), + customFields: z.record(z.string(), z.unknown()).optional(), }) export const jiraAddAttachmentInputSchema = z.object({ diff --git a/apps/sim/lib/internal/jira/operations.test.ts b/apps/sim/lib/internal/jira/operations.test.ts index 0dbaf326401..0a331ccbfc6 100644 --- a/apps/sim/lib/internal/jira/operations.test.ts +++ b/apps/sim/lib/internal/jira/operations.test.ts @@ -104,6 +104,35 @@ describe('Jira operations', () => { expect(result.output).not.toHaveProperty('assigneeId') }) + it('keeps the explicit assignee ahead of a customFields assignee on create', async () => { + mocks.request + .mockResolvedValueOnce({ + ok: true, + status: 201, + statusText: 'Created', + text: '{"id":"100","key":"PROJ-1","self":"jira-self","fields":{"summary":"Created"}}', + }) + .mockResolvedValueOnce({ ok: true, status: 204, statusText: 'No Content', text: '' }) + + await executeJiraWrite( + { + accessToken: 'token', + domain: 'example.atlassian.net', + projectId: 'PROJ', + summary: 'Created', + assignee: 'account-explicit', + customFields: { assignee: { accountId: 'account-other' }, customfield_10020: 5 }, + }, + context + ) + + const createBody = JSON.parse(String((mocks.request.mock.calls[0]?.[1] as RequestInit).body)) + expect(createBody.fields).not.toHaveProperty('assignee') + expect(createBody.fields.customfield_10020).toBe(5) + const assignBody = JSON.parse(String((mocks.request.mock.calls[1]?.[1] as RequestInit).body)) + expect(assignBody).toEqual({ accountId: 'account-explicit' }) + }) + it('rejects unsafe attachment issue keys before file or provider work', async () => { await expect( executeJiraAddAttachment( diff --git a/apps/sim/lib/internal/jira/operations.ts b/apps/sim/lib/internal/jira/operations.ts index a6025b3f61f..65e4f0adcee 100644 --- a/apps/sim/lib/internal/jira/operations.ts +++ b/apps/sim/lib/internal/jira/operations.ts @@ -100,6 +100,27 @@ function putOptionalIssueFields( } } +function toParentReference(parent: NonNullable): unknown { + if (typeof parent !== 'string') return parent + return /^\d+$/.test(parent) ? { id: parent } : { key: parent } +} + +/** + * Merges `customFields` under the explicit fields. `reserved` names fields an explicit + * param sets outside this body (the create flow assigns after creation), which + * `customFields` must not override either. + */ +function putAdditionalFields( + fields: JsonObject, + customFields: JsonObject | undefined, + reserved: string[] = [] +): void { + if (!customFields) return + for (const [fieldId, value] of Object.entries(customFields)) { + if (!(fieldId in fields) && !reserved.includes(fieldId)) fields[fieldId] = value + } +} + async function assignCreatedIssue( client: JiraClient, issueKey: string, @@ -149,15 +170,9 @@ export async function executeJiraWrite(input: JiraWriteInput, context: JiraOpera summary: input.summary, } putOptionalIssueFields(fields, input, false) - if (input.parent) { - fields.parent = - typeof input.parent === 'string' - ? /^\d+$/.test(input.parent) - ? { id: input.parent } - : { key: input.parent } - : input.parent - } + if (input.parent) fields.parent = toParentReference(input.parent) if (input.reporter) fields.reporter = { accountId: input.reporter } + putAdditionalFields(fields, input.customFields, input.assignee ? ['assignee'] : []) const response = await client.request( client.issuePath(), @@ -212,6 +227,8 @@ export async function executeJiraUpdate(input: JiraUpdateInput, context: JiraOpe const fields: JsonObject = {} if (input.summary) fields.summary = input.summary putOptionalIssueFields(fields, input, true) + if (input.parent) fields.parent = toParentReference(input.parent) + putAdditionalFields(fields, input.customFields) const notifyParam = input.notifyUsers === false ? '?notifyUsers=false' diff --git a/apps/sim/lib/internal/jsm/assets.ts b/apps/sim/lib/internal/jsm/assets.ts index 124cd90a560..de3edd4a823 100644 --- a/apps/sim/lib/internal/jsm/assets.ts +++ b/apps/sim/lib/internal/jsm/assets.ts @@ -1,4 +1,6 @@ -import { toArray } from '@sim/utils/object' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { toArray, toRecord } from '@sim/utils/object' import type { ContractBody } from '@/lib/api/contracts' import type { jsmCreateObjectContract, @@ -12,8 +14,11 @@ import type { jsmUpdateObjectContract, } from '@/lib/api/contracts/tools/jsm' import { createJsmAssetsClient } from '@/lib/internal/jsm/client' +import { JsmOperationError } from '@/lib/internal/jsm/errors' import { mapAssetObject } from '@/tools/jsm/utils' +const logger = createLogger('JsmAssets') + type ListObjectSchemasInput = ContractBody type GetObjectSchemaInput = ContractBody type ListObjectTypesInput = ContractBody @@ -74,22 +79,68 @@ export async function executeJsmGetObjectSchema(input: GetObjectSchemaInput, sig return { success: true, output: { ts: new Date().toISOString(), schema: schema ?? null } } } +/** Accepts an object-types listing as a bare array or a `values` wrapper. */ +function toObjectTypeList(value: unknown): unknown[] { + return Array.isArray(value) ? value : toArray(toRecord(value).values) +} + +function isAbort(error: unknown, signal?: AbortSignal): boolean { + if (signal?.aborted) return true + return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError') +} + +/** + * Reads the flat object-types listing used as a fallback for an empty schema listing. + * A failure other than an abort yields an empty list, so the fallback never fails a + * request the primary listing already answered. + */ +async function listFlatObjectTypes( + client: Awaited>, + schemaPath: string, + schemaId: string, + signal?: AbortSignal +): Promise { + try { + return toObjectTypeList( + await client.value(client.assets(`${schemaPath}/flat`), {}, signal, true) + ) + } catch (error) { + if (isAbort(error, signal)) throw error + logger.warn('Flat object-types fallback failed, returning the empty schema listing', { + schemaId, + error: getErrorMessage(error), + }) + return [] + } +} + +/** + * Lists a schema's object types. An empty schema listing is retried against the + * documented `/flat` listing, which returns every type in the schema, with + * `excludeAbstract` applied client-side because `/flat` does not accept it. + */ export async function executeJsmListObjectTypes(input: ListObjectTypesInput, signal?: AbortSignal) { const client = await createJsmAssetsClient(input, signal) const query = new URLSearchParams() if (input.excludeAbstract !== undefined) { query.append('excludeAbstract', String(input.excludeAbstract)) } - const value = await client.value( - client.assets( - `/objectschema/${encodeURIComponent(input.schemaId)}/objecttypes${query.size ? `?${query}` : ''}` - ), - {}, - signal, - true + const schemaPath = `/objectschema/${encodeURIComponent(input.schemaId)}/objecttypes` + let objectTypes = toObjectTypeList( + await client.value( + client.assets(`${schemaPath}${query.size ? `?${query}` : ''}`), + {}, + signal, + true + ) ) - const data = Array.isArray(value) ? {} : (value as Record) - const objectTypes = Array.isArray(value) ? value : toArray(data.values) + if (objectTypes.length === 0) { + const flat = await listFlatObjectTypes(client, schemaPath, input.schemaId, signal) + objectTypes = + String(input.excludeAbstract) === 'true' + ? flat.filter((objectType) => toRecord(objectType).abstractObjectType !== true) + : flat + } return { success: true, output: { ts: new Date().toISOString(), objectTypes, total: objectTypes.length }, @@ -122,23 +173,88 @@ export async function executeJsmGetObjectTypeAttributes( } } +const ASSETS_NUMERIC_ID = /^\d+$/ + +const AQL_ORDER_BY = /order\s+by\s/iy +const AQL_CLAUSE_BOUNDARY = /[\s)"]/ + +/** + * Splits a trailing `order by` clause off an AQL query, since AQL requires the ordering + * to stay at the end. Only a clause at parenthesis depth 0 and outside double-quoted + * values (including escaped `\\"`) counts, so `order by` inside a function argument or + * a string stays in the condition. + */ +function splitAqlOrderBy(qlQuery: string): { condition: string; orderBy: string } { + let inQuotes = false + let depth = 0 + let orderByStart = -1 + for (let index = 0; index < qlQuery.length; index++) { + const char = qlQuery[index] + if (char === '"') { + let backslashes = 0 + for (let cursor = index - 1; cursor >= 0 && qlQuery[cursor] === '\\'; cursor--) backslashes++ + if (backslashes % 2 === 0) inQuotes = !inQuotes + continue + } + if (inQuotes) continue + if (char === '(') depth++ + else if (char === ')') depth = Math.max(0, depth - 1) + if (depth !== 0) continue + if (index > 0 && !AQL_CLAUSE_BOUNDARY.test(qlQuery[index - 1] ?? '')) continue + AQL_ORDER_BY.lastIndex = index + if (AQL_ORDER_BY.test(qlQuery)) orderByStart = index + } + if (orderByStart === -1) return { condition: qlQuery, orderBy: '' } + return { condition: qlQuery.slice(0, orderByStart), orderBy: qlQuery.slice(orderByStart) } +} + +function scopeId(keyword: string, id: string | undefined): string | null { + const trimmed = id?.trim() + if (!trimmed) return null + if (!ASSETS_NUMERIC_ID.test(trimmed)) { + throw new JsmOperationError(`${keyword} must be a numeric ID`, 400) + } + return `${keyword} = ${trimmed}` +} + +/** ANDs the optional object type and schema scopes into the query's condition. */ +function scopeAqlQuery(input: SearchObjectsAqlInput): string { + const scopes = [ + scopeId('objectTypeId', input.objectTypeId), + scopeId('objectSchemaId', input.objectSchemaId), + ].filter((scope): scope is string => scope !== null) + if (scopes.length === 0) return input.qlQuery + const { condition, orderBy } = splitAqlOrderBy(input.qlQuery) + const trimmedCondition = condition.trim() + const scoped = trimmedCondition + ? `(${trimmedCondition}) AND ${scopes.join(' AND ')}` + : scopes.join(' AND ') + return orderBy ? `${scoped} ${orderBy}` : scoped +} + export async function executeJsmSearchObjectsAql( input: SearchObjectsAqlInput, signal?: AbortSignal ) { - const client = await createJsmAssetsClient(input, signal) + const page = Math.max(1, Math.trunc(toNumber(input.page, 1))) + const resultsPerPage = Math.max(1, Math.trunc(toNumber(input.resultsPerPage, 25))) const includeAttributes = input.includeAttributes === undefined ? true : String(input.includeAttributes) === 'true' - const body: Record = { - qlQuery: input.qlQuery, - page: toNumber(input.page, 1), - resultsPerPage: toNumber(input.resultsPerPage, 25), - includeAttributes, - } - if (input.objectTypeId) body.objectTypeId = input.objectTypeId - if (input.objectSchemaId) body.objectSchemaId = input.objectSchemaId - const data = await client.json(client.assets('/object/aql'), jsonBody('POST', body), signal, true) - const entries = toArray(data.objectEntries) + const qlQuery = scopeAqlQuery(input) + const client = await createJsmAssetsClient(input, signal) + const query = new URLSearchParams({ + startAt: String((page - 1) * resultsPerPage), + maxResults: String(resultsPerPage), + includeAttributes: String(includeAttributes), + }) + const data = await client.json( + client.assets(`/object/aql?${query}`), + jsonBody('POST', { qlQuery }), + signal, + true + ) + const entries = toArray(data.values) + const isLast = data.isLast === undefined ? undefined : String(data.isLast) === 'true' return { success: true, output: { @@ -146,9 +262,12 @@ export async function executeJsmSearchObjectsAql( objects: entries.map((entry) => mapAssetObject(entry as Parameters[0]) ), - total: data.totalFilterCount ?? entries.length, - pageNumber: data.pageNumber ?? 1, - pageSize: data.pageSize ?? entries.length, + total: data.total ?? entries.length, + pageNumber: page, + pageSize: entries.length, + startAt: data.startAt ?? (page - 1) * resultsPerPage, + maxResults: data.maxResults ?? resultsPerPage, + isLast: isLast ?? entries.length < resultsPerPage, }, } } diff --git a/apps/sim/lib/internal/jsm/operations.test.ts b/apps/sim/lib/internal/jsm/operations.test.ts index 11d219d331b..26af2c31418 100644 --- a/apps/sim/lib/internal/jsm/operations.test.ts +++ b/apps/sim/lib/internal/jsm/operations.test.ts @@ -1,3 +1,4 @@ +import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { @@ -35,8 +36,8 @@ vi.mock('@/lib/internal/jsm/client', () => ({ vi.mock('@/tools/jsm/utils', () => ({ mapAssetObject: mocks.mapAssetObject })) -import { executeJsmSearchObjectsAql } from '@/lib/internal/jsm/assets' -import { executeJsmCreateRequest } from '@/lib/internal/jsm/service-desk' +import { executeJsmListObjectTypes, executeJsmSearchObjectsAql } from '@/lib/internal/jsm/assets' +import { executeJsmCreateRequest, executeJsmGetRequests } from '@/lib/internal/jsm/service-desk' const BASE = { domain: 'example.atlassian.net', @@ -64,11 +65,35 @@ describe('JSM operations', () => { }) }) - it('normalizes Assets AQL pagination and forwards the execution signal', async () => { + it('accepts the organization ownership filter with its organization ID', async () => { + mocks.client.json.mockResolvedValueOnce({ values: [], size: 0, isLastPage: true }) + await executeJsmGetRequests({ + ...BASE, + requestOwnership: 'ORGANIZATION', + organizationId: '3', + }) + expect(mocks.client.json.mock.calls.at(-1)?.[0]).toBe( + 'service:/request?requestOwnership=ORGANIZATION&organizationId=3' + ) + }) + + it('sends AQL paging as startAt/maxResults query params and reads the values page', async () => { const controller = new AbortController() - mocks.client.json.mockResolvedValueOnce({ objectEntries: [{ id: 'object-1' }] }) - await executeJsmSearchObjectsAql( - { ...BASE, qlQuery: 'objectType = Host', page: '2', resultsPerPage: '10' }, + mocks.client.json.mockResolvedValueOnce({ + startAt: 10, + maxResults: 10, + total: 11, + isLast: 'true', + values: [{ id: 'object-1' }], + }) + const result = await executeJsmSearchObjectsAql( + { + ...BASE, + qlQuery: 'objectType = Host', + page: '2', + resultsPerPage: '10', + objectSchemaId: '6', + }, controller.signal ) expect(mocks.createJsmAssetsClient).toHaveBeenCalledWith( @@ -76,18 +101,137 @@ describe('JSM operations', () => { controller.signal ) expect(mocks.client.json).toHaveBeenCalledWith( - 'assets:/object/aql', + 'assets:/object/aql?startAt=10&maxResults=10&includeAttributes=true', { method: 'POST', - body: JSON.stringify({ - qlQuery: 'objectType = Host', - page: 2, - resultsPerPage: 10, - includeAttributes: true, - }), + body: JSON.stringify({ qlQuery: '(objectType = Host) AND objectSchemaId = 6' }), }, controller.signal, true ) + expect(result.output).toMatchObject({ + objects: [{ id: 'object-1' }], + total: 11, + pageNumber: 2, + isLast: true, + }) + }) + + it('rejects a non-numeric AQL scope ID instead of splicing it into the query', async () => { + await expect( + executeJsmSearchObjectsAql({ + ...BASE, + qlQuery: 'objectType = Host', + objectTypeId: '1 OR objectType != Host', + }) + ).rejects.toThrow('objectTypeId must be a numeric ID') + }) + + it('falls back to the flat object-types listing when the schema listing comes back empty', async () => { + mocks.client.value.mockReset() + mocks.client.value.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { id: '1', name: 'Host', abstractObjectType: false }, + { id: '2', name: 'Base', abstractObjectType: true }, + ]) + const result = await executeJsmListObjectTypes({ + ...BASE, + schemaId: '6', + excludeAbstract: true, + }) + expect(mocks.client.value.mock.calls.map((call) => call[0])).toEqual([ + 'assets:/objectschema/6/objecttypes?excludeAbstract=true', + 'assets:/objectschema/6/objecttypes/flat', + ]) + expect(result.output).toMatchObject({ objectTypes: [{ id: '1', name: 'Host' }], total: 1 }) + }) + + it('keeps a non-empty schema listing without a second request', async () => { + mocks.client.value.mockReset() + mocks.client.value.mockResolvedValueOnce([{ id: '1', name: 'Host' }]) + const result = await executeJsmListObjectTypes({ ...BASE, schemaId: '6' }) + expect(mocks.client.value).toHaveBeenCalledTimes(1) + expect(result.output).toMatchObject({ objectTypes: [{ id: '1', name: 'Host' }], total: 1 }) + }) + + it('keeps a trailing AQL order by clause after the scope filter', async () => { + mocks.client.json.mockResolvedValueOnce({ values: [], total: 0, isLast: 'true' }) + await executeJsmSearchObjectsAql({ + ...BASE, + qlQuery: 'Name = "sort order by date" ORDER BY "Created Date" desc', + objectSchemaId: '6', + }) + expect(mocks.client.json.mock.calls.at(-1)?.[1]).toEqual({ + method: 'POST', + body: JSON.stringify({ + qlQuery: + '(Name = "sort order by date") AND objectSchemaId = 6 ORDER BY "Created Date" desc', + }), + }) + }) + + it.each([ + [{ approvalStatus: 'MY_PENDING_APPROVAL', requestOwnership: 'OWNED_REQUESTS' }, 'APPROVER'], + [{ organizationId: '3', requestOwnership: 'APPROVER' }, 'ORGANIZATION'], + ])( + 'rejects %o, which Atlassian only accepts with requestOwnership %s', + async (filters, owner) => { + await expect(executeJsmGetRequests({ ...BASE, ...filters })).rejects.toThrow(owner) + } + ) + + it('does not split an order by inside an escaped quote', async () => { + mocks.client.json.mockResolvedValueOnce({ values: [], total: 0, isLast: 'true' }) + await executeJsmSearchObjectsAql({ + ...BASE, + qlQuery: 'Name = "say \\" order by \\" x" order by Name', + objectTypeId: '7', + }) + expect(JSON.parse(String(mocks.client.json.mock.calls.at(-1)?.[1]?.body))).toEqual({ + qlQuery: '(Name = "say \\" order by \\" x") AND objectTypeId = 7 order by Name', + }) + }) + + it.each([ + ['order by Name', 'objectTypeId = 7 order by Name'], + [' ORDER BY "Created Date" desc', 'objectTypeId = 7 ORDER BY "Created Date" desc'], + [ + 'object having inboundReferences(objectType = Host order by Name)', + '(object having inboundReferences(objectType = Host order by Name)) AND objectTypeId = 7', + ], + [ + 'object having inboundReferences(objectType = Host order by Name) order by Key', + '(object having inboundReferences(objectType = Host order by Name)) AND objectTypeId = 7 order by Key', + ], + ])('scopes the AQL query %j as %j', async (qlQuery, expected) => { + mocks.client.json.mockResolvedValueOnce({ values: [], total: 0, isLast: 'true' }) + await executeJsmSearchObjectsAql({ ...BASE, qlQuery, objectTypeId: '7' }) + expect(JSON.parse(String(mocks.client.json.mock.calls.at(-1)?.[1]?.body))).toEqual({ + qlQuery: expected, + }) + }) + + it('returns the empty schema listing when the flat fallback fails', async () => { + mocks.client.value.mockReset() + mocks.client.value + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error('Jira request failed with status 404')) + const result = await executeJsmListObjectTypes({ ...BASE, schemaId: '6' }) + expect(result.output).toMatchObject({ objectTypes: [], total: 0 }) + expect(getMockLogger('JsmAssets').warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ schemaId: '6' }) + ) + }) + + it('still propagates an abort from the flat fallback', async () => { + const controller = new AbortController() + mocks.client.value.mockReset() + mocks.client.value.mockResolvedValueOnce([]).mockImplementationOnce(async () => { + controller.abort() + throw new DOMException('The operation was aborted.', 'AbortError') + }) + await expect( + executeJsmListObjectTypes({ ...BASE, schemaId: '6' }, controller.signal) + ).rejects.toThrow('aborted') }) }) diff --git a/apps/sim/lib/internal/jsm/service-desk.ts b/apps/sim/lib/internal/jsm/service-desk.ts index 97857d93110..90cbf74a91b 100644 --- a/apps/sim/lib/internal/jsm/service-desk.ts +++ b/apps/sim/lib/internal/jsm/service-desk.ts @@ -167,8 +167,11 @@ const REQUEST_OWNERSHIP = [ 'PARTICIPATED_REQUESTS', 'APPROVER', 'ALL_REQUESTS', + 'ORGANIZATION', + 'ALL_ORGANIZATIONS', ] as const const REQUEST_STATUS = ['OPEN_REQUESTS', 'CLOSED_REQUESTS', 'ALL_REQUESTS'] as const +const APPROVAL_STATUS = ['MY_PENDING_APPROVAL', 'MY_HISTORY_APPROVAL'] as const function requireAction( action: string, @@ -193,11 +196,24 @@ export async function executeJsmGetRequests(input: JsmRequestsBody, signal?: Abo const result = validateEnum(input.requestStatus, REQUEST_STATUS, 'requestStatus') if (!result.isValid) throw new JsmOperationError(result.error || 'Invalid requestStatus', 400) } + if (input.approvalStatus) { + const result = validateEnum(input.approvalStatus, APPROVAL_STATUS, 'approvalStatus') + if (!result.isValid) throw new JsmOperationError(result.error || 'Invalid approvalStatus', 400) + } + if (input.approvalStatus && input.requestOwnership !== 'APPROVER') { + throw new JsmOperationError('approvalStatus requires requestOwnership APPROVER', 400) + } + if (input.organizationId) validateId(input.organizationId, 'organizationId') + if (input.organizationId && input.requestOwnership !== 'ORGANIZATION') { + throw new JsmOperationError('organizationId requires requestOwnership ORGANIZATION', 400) + } const client = await createJsmClient(input, signal) const query = new URLSearchParams() append(query, 'serviceDeskId', input.serviceDeskId) append(query, 'requestOwnership', input.requestOwnership) append(query, 'requestStatus', input.requestStatus) + append(query, 'approvalStatus', input.approvalStatus) + append(query, 'organizationId', input.organizationId) append(query, 'requestTypeId', input.requestTypeId) append(query, 'searchTerm', input.searchTerm) append(query, 'expand', input.expand) @@ -223,6 +239,27 @@ function currentStatus(data: Record) { : null } +function webUrl(data: Record): string | null { + const web = toRecord(data._links).web + return typeof web === 'string' ? web : null +} + +function requestFieldValues(data: Record) { + return toArray(data.requestFieldValues).map((entry) => { + const field = toRecord(entry) + return { + fieldId: field.fieldId ?? null, + label: field.label ?? null, + value: field.value ?? null, + ...(field.renderedValue !== undefined ? { renderedValue: field.renderedValue } : {}), + } + }) +} + +function expandedValues(value: unknown): unknown[] | null { + return value === undefined ? null : toArray(toRecord(value).values) +} + function reporter(data: Record, includeActive: boolean) { if (!data.reporter) return null const value = toRecord(data.reporter) @@ -285,8 +322,10 @@ export async function executeJsmCreateRequest(input: JsmRequestBody, signal?: Ab createdDate: data.createdDate ?? null, currentStatus: currentStatus(data), reporter: reporter(data, false), + requestFieldValues: requestFieldValues(data), success: true, url: `https://${input.domain}/browse/${String(data.issueKey)}`, + webUrl: webUrl(data), }, } } @@ -316,15 +355,11 @@ export async function executeJsmGetRequest(input: JsmRequestBody, signal?: Abort createdDate: data.createdDate ?? null, currentStatus: currentStatus(data), reporter: reporter(data, true), - requestFieldValues: toArray(data.requestFieldValues).map((entry) => { - const field = toRecord(entry) - return { - fieldId: field.fieldId ?? null, - label: field.label ?? null, - value: field.value ?? null, - } - }), + requestFieldValues: requestFieldValues(data), url: `https://${input.domain}/browse/${String(data.issueKey)}`, + webUrl: webUrl(data), + participants: expandedValues(data.participants), + slas: expandedValues(data.sla), request: data, }, } diff --git a/apps/sim/lib/internal/lambda/client.ts b/apps/sim/lib/internal/lambda/client.ts index 2f83944c4d9..8fc8c2a71e9 100644 --- a/apps/sim/lib/internal/lambda/client.ts +++ b/apps/sim/lib/internal/lambda/client.ts @@ -211,6 +211,7 @@ export function mapFunctionConfiguration(fc: FunctionConfiguration) { ? { retentionPeriodInDays: fc.DurableConfig.RetentionPeriodInDays ?? null, executionTimeout: fc.DurableConfig.ExecutionTimeout ?? null, + kmsKeyArn: fc.DurableConfig.KMSKeyArn ?? null, } : null, tenancyConfig: fc.TenancyConfig diff --git a/apps/sim/lib/internal/lambda/operations.ts b/apps/sim/lib/internal/lambda/operations.ts index 463d5940454..4d85fe56b7a 100644 --- a/apps/sim/lib/internal/lambda/operations.ts +++ b/apps/sim/lib/internal/lambda/operations.ts @@ -1,5 +1,6 @@ import { AddPermissionCommand, + type ApplicationLogLevel, type Cors, CreateAliasCommand, CreateEventSourceMappingCommand, @@ -42,6 +43,8 @@ import { ListProvisionedConcurrencyConfigsCommand, ListTagsCommand, ListVersionsByFunctionCommand, + type LogFormat, + type LoggingConfig, type LogType, PublishVersionCommand, PutFunctionConcurrencyCommand, @@ -55,6 +58,7 @@ import { type SnapStartApplyOn, type SourceAccessConfiguration, type SourceAccessType, + type SystemLogLevel, TagResourceCommand, type TracingMode, UntagResourceCommand, @@ -182,11 +186,18 @@ function toVpcConfig(subnetIds?: string[], securityGroupIds?: string[]) { } /** Builds the SDK `LoggingConfig` wrapper, omitted entirely when no field was supplied. */ -function toLoggingConfig(logFormat?: string, logGroup?: string) { - if (!logFormat && !logGroup) return undefined +function toLoggingConfig( + logFormat?: string, + logGroup?: string, + applicationLogLevel?: ApplicationLogLevel, + systemLogLevel?: SystemLogLevel +): LoggingConfig | undefined { + if (!logFormat && !logGroup && !applicationLogLevel && !systemLogLevel) return undefined return { - ...(logFormat ? { LogFormat: logFormat as 'JSON' | 'Text' } : {}), + ...(logFormat ? { LogFormat: logFormat as LogFormat } : {}), ...(logGroup ? { LogGroup: logGroup } : {}), + ...(applicationLogLevel ? { ApplicationLogLevel: applicationLogLevel } : {}), + ...(systemLogLevel ? { SystemLogLevel: systemLogLevel } : {}), } } @@ -244,6 +255,7 @@ export async function executeLambdaInvoke(input: AwsLambdaInvokeBody, signal?: A LogType: input.logType as LogType | undefined, ClientContext: input.clientContext, Qualifier: input.qualifier, + TenantId: input.tenantId, Payload: encodeInvocationPayload(input.payload), }), { abortSignal: signal } @@ -313,6 +325,12 @@ export async function executeLambdaGetFunction( imageUri: response.Code.ImageUri ?? null, resolvedImageUri: response.Code.ResolvedImageUri ?? null, sourceKmsKeyArn: response.Code.SourceKMSKeyArn ?? null, + error: response.Code.Error + ? { + errorCode: response.Code.Error.ErrorCode ?? null, + message: response.Code.Error.Message ?? null, + } + : null, } : null, tags: response.Tags ?? {}, @@ -457,7 +475,12 @@ export async function executeLambdaUpdateFunctionConfiguration( SnapStart: input.snapStartApplyOn ? { ApplyOn: input.snapStartApplyOn as SnapStartApplyOn } : undefined, - LoggingConfig: toLoggingConfig(input.logFormat, input.logGroup), + LoggingConfig: toLoggingConfig( + input.logFormat, + input.logGroup, + input.applicationLogLevel, + input.systemLogLevel + ), RevisionId: input.revisionId, }), { abortSignal: signal } diff --git a/apps/sim/lib/internal/llm/bedrock-credential.integration.ts b/apps/sim/lib/internal/llm/bedrock-credential.integration.ts new file mode 100644 index 00000000000..458e811e443 --- /dev/null +++ b/apps/sim/lib/internal/llm/bedrock-credential.integration.ts @@ -0,0 +1,207 @@ +/** A saved AWS credential resolved for a Bedrock model call against real credential rows. */ + +import { withAuditRollupScope } from '@sim/audit' +import { db } from '@sim/db' +import { + auditLog, + credential, + credentialMember, + permissions, + user, + workspace, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + BedrockCredentialError, + resolveBedrockCredentialSession, +} from '@/lib/internal/llm/bedrock-credential' + +const workspaceId = generateId() +const otherWorkspaceId = generateId() +const memberId = generateId() +const outsiderId = generateId() +const awsCredentialId = generateId() +const revokedAwsCredentialId = generateId() +const otherWorkspaceAwsCredentialId = generateId() +const googleCredentialId = generateId() +const userIds = [memberId, outsiderId] + +const ACCESS_KEY_ID = 'AKIAFIXTUREKEY000001' +const SECRET_ACCESS_KEY = 'fixture-secret-access-key' + +async function storedAwsKeys(): Promise { + const { encrypted } = await encryptSecret( + JSON.stringify({ + type: 'aws_credential_v1', + config: { + authType: 'access_keys', + accessKeyId: ACCESS_KEY_ID, + secretAccessKey: SECRET_ACCESS_KEY, + }, + }) + ) + return encrypted +} + +function resolveAs(actingUserId: string | undefined, credentialId: string, scope = workspaceId) { + return resolveBedrockCredentialSession({ + credentialId, + actingUserId, + workspaceId: scope, + executionId: 'bedrock-credential-fixture', + }) +} + +describe('resolveBedrockCredentialSession', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values( + userIds.map((id) => ({ + id, + name: 'Bedrock credential fixture', + email: `${id}@bedrock-credential.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await insertWorkspaceFixture( + db, + [workspaceId, otherWorkspaceId].map((id) => ({ + id, + name: 'Bedrock credential fixture', + ownerId: memberId, + billedAccountUserId: memberId, + })) + ) + await db.insert(permissions).values( + ( + [ + [memberId, workspaceId], + [memberId, otherWorkspaceId], + [outsiderId, workspaceId], + ] as const + ).map(([userId, entityId]) => ({ + id: generateId(), + userId, + entityType: 'workspace', + entityId, + permissionType: 'write' as const, + })) + ) + const encryptedKeys = await storedAwsKeys() + await db.insert(credential).values([ + { + id: awsCredentialId, + type: 'service_account', + workspaceId, + providerId: 'aws-service-account', + displayName: 'AWS fixture', + encryptedServiceAccountKey: encryptedKeys, + createdBy: memberId, + }, + { + id: revokedAwsCredentialId, + type: 'service_account', + workspaceId, + providerId: 'aws-service-account', + displayName: 'Revoked AWS fixture', + encryptedServiceAccountKey: encryptedKeys, + revokedAt: now, + createdBy: memberId, + }, + { + id: otherWorkspaceAwsCredentialId, + type: 'service_account', + workspaceId: otherWorkspaceId, + providerId: 'aws-service-account', + displayName: 'Other workspace AWS fixture', + encryptedServiceAccountKey: encryptedKeys, + createdBy: memberId, + }, + { + id: googleCredentialId, + type: 'service_account', + workspaceId, + providerId: 'google-service-account', + displayName: 'Google fixture', + encryptedServiceAccountKey: encryptedKeys, + createdBy: memberId, + }, + ]) + await db.insert(credentialMember).values( + [ + awsCredentialId, + revokedAwsCredentialId, + otherWorkspaceAwsCredentialId, + googleCredentialId, + ].map((credentialId) => ({ + id: generateId(), + credentialId, + userId: memberId, + role: 'member' as const, + status: 'active' as const, + })) + ) + }) + + afterAll(async () => { + await db.delete(auditLog).where(inArray(auditLog.workspaceId, [workspaceId, otherWorkspaceId])) + await deleteWorkspaceFixture(db, inArray(workspace.id, [workspaceId, otherWorkspaceId])) + await db.delete(user).where(inArray(user.id, userIds)) + }) + + it("resolves a credential member's saved access keys for the credential's workspace", async () => { + await expect(withAuditRollupScope(() => resolveAs(memberId, awsCredentialId))).resolves.toEqual( + { + credentialId: awsCredentialId, + accessKeyId: ACCESS_KEY_ID, + secretAccessKey: SECRET_ACCESS_KEY, + } + ) + + const audits = await db + .select({ actorId: auditLog.actorId }) + .from(auditLog) + .where(and(eq(auditLog.workspaceId, workspaceId), eq(auditLog.resourceId, awsCredentialId))) + expect(audits).toEqual([{ actorId: memberId }]) + }) + + it('refuses a run with no acting user', async () => { + await expect(resolveAs(undefined, awsCredentialId)).rejects.toBeInstanceOf( + BedrockCredentialError + ) + }) + + it('refuses a workspace member the credential was never shared with', async () => { + await expect(resolveAs(outsiderId, awsCredentialId)).rejects.toBeInstanceOf( + BedrockCredentialError + ) + }) + + it('refuses a credential from another workspace than the run', async () => { + await expect(resolveAs(memberId, otherWorkspaceAwsCredentialId)).rejects.toBeInstanceOf( + BedrockCredentialError + ) + }) + + it('refuses a saved credential of another provider', async () => { + await expect(resolveAs(memberId, googleCredentialId)).rejects.toThrow( + 'Could not use the AWS credential: AWS credential is unavailable' + ) + }) + + it('refuses a revoked AWS credential', async () => { + await expect(resolveAs(memberId, revokedAwsCredentialId)).rejects.toThrow( + 'Could not use the AWS credential: AWS credential is unavailable' + ) + }) + + it('refuses an unknown credential id', async () => { + await expect(resolveAs(memberId, generateId())).rejects.toBeInstanceOf(BedrockCredentialError) + }) +}) diff --git a/apps/sim/lib/internal/llm/bedrock-credential.ts b/apps/sim/lib/internal/llm/bedrock-credential.ts new file mode 100644 index 00000000000..dd98e4d9ebb --- /dev/null +++ b/apps/sim/lib/internal/llm/bedrock-credential.ts @@ -0,0 +1,117 @@ +import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access' +import { AuthType } from '@/lib/auth/hybrid' +import { resolveAuthorizedAwsCredential } from '@/lib/internal/aws/credentials' +import { awsCredentialFailureMessage, isTransientAwsFailure } from '@/lib/internal/aws/failure' +import { recordCredentialAccess } from '@/lib/oauth/token-resolution' +import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' +import type { BedrockCredentialSession } from '@/providers/types' + +type BedrockCredentialFailure = 'unauthorized' | 'unusable' | 'unavailable' + +/** + * A saved AWS credential could not be used for a Bedrock request: the caller is + * not allowed to use it (`unauthorized`), it is not a live AWS credential or AWS + * refused it (`unusable`), or AWS was briefly unreachable while resolving it + * (`unavailable`, worth retrying). + */ +export class BedrockCredentialError extends Error { + readonly reason: BedrockCredentialFailure + + constructor(message: string, options?: { reason?: BedrockCredentialFailure; cause?: unknown }) { + super(message, { cause: options?.cause }) + this.name = 'BedrockCredentialError' + this.reason = options?.reason ?? 'unusable' + } +} + +export interface ResolveBedrockCredentialParams { + /** The saved credential selected on the block; its ID is never trusted beyond this lookup. */ + credentialId: string + /** The user the run acts for; must be allowed to use the credential. */ + actingUserId: string | undefined + /** Pins the credential to this workflow's workspace. */ + workflowId?: string + /** Pins the credential to this workspace when there is no workflow. */ + workspaceId?: string + /** Names the STS role session after the run, so it shows in the customer's CloudTrail. */ + executionId?: string + signal?: AbortSignal +} + +/** + * Authorizes the acting user's use of a saved AWS credential for a Bedrock model + * call and resolves it to signing credentials: the stored key pair for access + * keys, or a fresh STS session for an assumed or keyless role. + * + * Authorization is the same check every other saved-credential use passes + * (`authorizeCredentialUseForAuth`): the user must be allowed to use the + * credential, and it must belong to the workflow's workspace. The credential is + * then loaded only within that workspace and only as a live AWS credential, so + * any other credential fails here and the request never reaches Bedrock: a + * selected credential is never replaced by inline keys or the deployment's own + * AWS credentials. + */ +export async function resolveBedrockCredentialSession({ + credentialId, + actingUserId, + workflowId, + workspaceId, + executionId, + signal, +}: ResolveBedrockCredentialParams): Promise { + if (!actingUserId) { + throw new BedrockCredentialError('Using an AWS credential requires an authenticated user', { + reason: 'unauthorized', + }) + } + + const access = await authorizeCredentialUseForAuth( + { success: true, userId: actingUserId, authType: AuthType.INTERNAL_JWT }, + { credentialId, workflowId, workspaceId, callerUserId: actingUserId } + ) + if (!access.ok) { + throw new BedrockCredentialError(access.error || 'Not authorized to use this AWS credential', { + reason: 'unauthorized', + }) + } + if ( + access.credentialType !== 'service_account' || + !access.resolvedCredentialId || + !access.workspaceId + ) { + throw new BedrockCredentialError('AWS Bedrock needs a saved AWS credential') + } + signal?.throwIfAborted() + + let session: Awaited> + try { + session = await resolveAuthorizedAwsCredential( + access.resolvedCredentialId, + { workspaceId: access.workspaceId, executionId }, + signal + ) + } catch (error) { + signal?.throwIfAborted() + throw new BedrockCredentialError(awsCredentialFailureMessage(error), { + reason: isTransientAwsFailure(error) ? 'unavailable' : 'unusable', + cause: error, + }) + } + + recordCredentialAccess({ + actorId: actingUserId, + workspaceId: access.workspaceId, + resourceId: access.resolvedCredentialId, + providerId: AWS_SERVICE_ACCOUNT_PROVIDER_ID, + credentialType: 'service_account', + workflowId, + executionId, + }) + + return { + credentialId: access.resolvedCredentialId, + accessKeyId: session.accessKeyId, + secretAccessKey: session.secretAccessKey, + ...(session.sessionToken ? { sessionToken: session.sessionToken } : {}), + } +} diff --git a/apps/sim/lib/internal/llm/execute-tool.ts b/apps/sim/lib/internal/llm/execute-tool.ts index 17307d0c79b..c2b4e0e13e3 100644 --- a/apps/sim/lib/internal/llm/execute-tool.ts +++ b/apps/sim/lib/internal/llm/execute-tool.ts @@ -54,6 +54,7 @@ export const executeLlmTool: InternalToolOperationHandler = async (request) => { actorUserId: request.context.userId, headers: request.headers, requestId: request.requestId, + executionId: request.context.executionId, signal: request.signal, }) request.signal?.throwIfAborted() diff --git a/apps/sim/lib/internal/llm/input.ts b/apps/sim/lib/internal/llm/input.ts index 926db4af854..ff59f8d6524 100644 --- a/apps/sim/lib/internal/llm/input.ts +++ b/apps/sim/lib/internal/llm/input.ts @@ -72,6 +72,7 @@ export const llmProviderOperationInputSchema = z bedrockAccessKeyId: z.string().optional(), bedrockSecretKey: z.string().optional(), bedrockRegion: z.string().optional(), + bedrockCredential: z.string().optional(), responseFormat: providerResponseFormatSchema.optional(), workflowId: z.string().optional(), workspaceId: z.string().optional(), diff --git a/apps/sim/lib/internal/llm/operations.test.ts b/apps/sim/lib/internal/llm/operations.test.ts index d6c76e75ccd..93209b0370a 100644 --- a/apps/sim/lib/internal/llm/operations.test.ts +++ b/apps/sim/lib/internal/llm/operations.test.ts @@ -2,6 +2,10 @@ import { billingAttributionMock, billingAttributionMockFns, } from '@sim/testing/mocks/billing-attribution.mock' +import { + internalAwsCredentialsMock, + internalAwsCredentialsMockFns, +} from '@sim/testing/mocks/internal-aws-credentials.mock' import { mothershipEnvironmentContextMock, mothershipEnvironmentContextMockFns, @@ -27,6 +31,8 @@ vi.mock('@/lib/mothership/environment-context', () => mothershipEnvironmentConte vi.mock('@/lib/internal/llm/credentials', () => ({ resolveVertexAccessToken: mocks.resolveVertexAccessToken, })) +vi.mock('@/lib/internal/aws/credentials', () => internalAwsCredentialsMock) +vi.mock('@/lib/oauth/token-resolution', () => ({ recordCredentialAccess: vi.fn() })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/executor/utils/resolved-secret-content-projection', () => ({ projectResolvedSecretModelContent: vi.fn(), @@ -34,6 +40,7 @@ vi.mock('@/executor/utils/resolved-secret-content-projection', () => ({ vi.mock('@/ee/access-control/utils/permission-check', () => permissionCheckMock) const { mockRequireBillingAttributionHeader } = billingAttributionMockFns +const { mockResolveAuthorizedAwsCredential } = internalAwsCredentialsMockFns import type { LlmOperationError } from '@/lib/internal/llm/errors' import { executeLlmProviderOperation } from '@/lib/internal/llm/operations' @@ -47,6 +54,20 @@ const BILLING_ATTRIBUTION = { billingEntity: { type: 'user' as const, id: 'user-1' }, } +const BEDROCK_CREDENTIAL_INPUT = { + provider: 'bedrock', + model: 'bedrock/claude', + bedrockCredential: 'aws-cred', + workspaceId: 'workspace-1', +} + +const AUTHORIZED_AWS_CREDENTIAL = { + ok: true, + credentialType: 'service_account', + resolvedCredentialId: 'aws-cred', + workspaceId: 'workspace-1', +} + describe('executeLlmProviderOperation', () => { beforeEach(() => { permissionsMockFns.mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true }) @@ -110,4 +131,111 @@ describe('executeLlmProviderOperation', () => { expect.anything() ) }) + + it("signs a Bedrock call with the selected AWS credential's session for the caller", async () => { + mocks.authorizeCredential.mockResolvedValueOnce(AUTHORIZED_AWS_CREDENTIAL) + mockResolveAuthorizedAwsCredential.mockResolvedValueOnce({ + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + }) + + await executeLlmProviderOperation( + { ...BEDROCK_CREDENTIAL_INPUT, bedrockRegion: 'eu-west-1', workflowId: 'workflow-1' }, + { + actorUserId: 'user-1', + headers: new Headers(), + requestId: 'request-1', + executionId: 'execution-1', + } + ) + + expect(mockResolveAuthorizedAwsCredential).toHaveBeenCalledWith( + 'aws-cred', + { workspaceId: 'workspace-1', executionId: 'execution-1' }, + undefined + ) + + expect(mocks.authorizeCredential).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + credentialId: 'aws-cred', + workflowId: 'workflow-1', + callerUserId: 'user-1', + }) + ) + expect(mockExecuteProviderRequest).toHaveBeenCalledWith( + 'bedrock', + expect.objectContaining({ + bedrockSession: { + credentialId: 'aws-cred', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + }, + bedrockRegion: 'eu-west-1', + }), + expect.anything() + ) + }) + + it('refuses a Bedrock credential the caller may not use with the status a Vertex refusal gets', async () => { + mockExecuteProviderRequest.mockClear() + mocks.authorizeCredential.mockResolvedValue({ ok: false, error: 'Credential access denied' }) + + for (const input of [ + BEDROCK_CREDENTIAL_INPUT, + { provider: 'vertex', model: 'vertex/gemini-2.5-pro', vertexCredential: 'credential-1' }, + ]) { + await expect( + executeLlmProviderOperation(input, { + actorUserId: 'user-1', + headers: new Headers(), + requestId: 'request-1', + }) + ).rejects.toMatchObject({ status: 401, body: { error: 'Credential access denied' } }) + } + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('fails a Bedrock call whose AWS credential cannot be resolved before provider work', async () => { + mockExecuteProviderRequest.mockClear() + mocks.authorizeCredential.mockResolvedValueOnce(AUTHORIZED_AWS_CREDENTIAL) + mockResolveAuthorizedAwsCredential.mockRejectedValueOnce(new Error('AccessDenied')) + + await expect( + executeLlmProviderOperation(BEDROCK_CREDENTIAL_INPUT, { + actorUserId: 'user-1', + headers: new Headers(), + requestId: 'request-1', + }) + ).rejects.toMatchObject({ + status: 400, + body: { error: 'Could not use the AWS credential: AccessDenied' }, + }) + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('reports an AWS credential STS could not resolve for a moment as unavailable, not invalid', async () => { + mockExecuteProviderRequest.mockClear() + mocks.authorizeCredential.mockResolvedValueOnce(AUTHORIZED_AWS_CREDENTIAL) + mockResolveAuthorizedAwsCredential.mockRejectedValueOnce( + Object.assign(new Error('Rate exceeded'), { + name: 'ThrottlingException', + $metadata: { httpStatusCode: 400 }, + }) + ) + + await expect( + executeLlmProviderOperation(BEDROCK_CREDENTIAL_INPUT, { + actorUserId: 'user-1', + headers: new Headers(), + requestId: 'request-1', + }) + ).rejects.toMatchObject({ + status: 503, + body: { error: 'Could not use the AWS credential: Rate exceeded' }, + }) + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/internal/llm/operations.ts b/apps/sim/lib/internal/llm/operations.ts index c34f1e2ec1f..528b4954b11 100644 --- a/apps/sim/lib/internal/llm/operations.ts +++ b/apps/sim/lib/internal/llm/operations.ts @@ -12,6 +12,10 @@ import { inspectModelInputProjectionState, inspectModelInputProvenanceRequest, } from '@/lib/execution/model-input-provenance' +import { + BedrockCredentialError, + resolveBedrockCredentialSession, +} from '@/lib/internal/llm/bedrock-credential' import { resolveVertexAccessToken } from '@/lib/internal/llm/credentials' import { LlmOperationError } from '@/lib/internal/llm/errors' import type { LlmProviderOperationInput } from '@/lib/internal/llm/input' @@ -36,6 +40,8 @@ export interface LlmProviderOperationContext { actorUserId: string headers: Headers requestId: string + /** The trusted workflow run this call belongs to, when it comes from one. */ + executionId?: string signal?: AbortSignal } @@ -124,19 +130,41 @@ async function prepareProviderRequest( runtimeContext: Awaited> }> { let apiKey = input.apiKey + let bedrockSession: ProviderRequest['bedrockSession'] try { await authorizeVertexCredential(input, context) if (input.provider === 'vertex' && input.vertexCredential) { apiKey = await resolveVertexAccessToken(context.requestId, input.vertexCredential) } + if (input.provider === 'bedrock' && input.bedrockCredential) { + bedrockSession = await resolveBedrockCredentialSession({ + credentialId: input.bedrockCredential, + actingUserId: context.actorUserId, + workflowId: input.workflowId || undefined, + workspaceId: input.workspaceId, + executionId: context.executionId, + signal: context.signal, + }) + } } catch (error) { if (error instanceof LlmOperationError) throw error - logger.error('Failed to resolve Vertex credential', { + if (error instanceof BedrockCredentialError && error.reason === 'unauthorized') { + logger.warn('Bedrock credential access denied', { + requestId: context.requestId, + credentialId: input.bedrockCredential, + }) + fail(401, error.message) + } + if (error instanceof BedrockCredentialError && error.reason === 'unavailable') { + fail(503, error.message) + } + logger.error('Failed to resolve provider credential', { requestId: context.requestId, provider: input.provider, model: input.model, error: toError(error).message, hasVertexCredential: Boolean(input.vertexCredential), + hasBedrockCredential: Boolean(input.bedrockCredential), }) fail(400, getErrorMessage(error, 'Credential error')) } @@ -157,6 +185,7 @@ async function prepareProviderRequest( bedrockAccessKeyId: input.bedrockAccessKeyId, bedrockSecretKey: input.bedrockSecretKey, bedrockRegion: input.bedrockRegion, + bedrockSession, responseFormat: input.responseFormat, workflowId: input.workflowId, workspaceId: input.workspaceId, diff --git a/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.ts b/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.ts index 73d3a03f79c..fd0e7ff4d45 100644 --- a/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.ts +++ b/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.ts @@ -9,6 +9,7 @@ import type { MicrosoftAdAddUserAppRoleAssignmentResponse, } from '@/tools/microsoft_ad/types' import { extractGraphErrorMessage, resolveGraphUserObjectId } from '@/tools/microsoft_ad/utils' +import { safeUrlPathSegment } from '@/tools/url-path' export const executeAddUserAppRoleAssignmentOperation: InternalToolOperationImplementation< MicrosoftAdAddUserAppRoleAssignmentParams @@ -17,7 +18,7 @@ export const executeAddUserAppRoleAssignmentOperation: InternalToolOperationImpl const principalId = await resolveGraphUserObjectId(userId, params.accessToken, signal) const response = await fetch( - `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/appRoleAssignments`, + `https://graph.microsoft.com/v1.0/users/${safeUrlPathSegment(userId, 'userId')}/appRoleAssignments`, { method: 'POST', headers: { diff --git a/apps/sim/lib/internal/microsoft-teams/operations.ts b/apps/sim/lib/internal/microsoft-teams/operations.ts index 99a77afb00c..7dad3cef30f 100644 --- a/apps/sim/lib/internal/microsoft-teams/operations.ts +++ b/apps/sim/lib/internal/microsoft-teams/operations.ts @@ -277,6 +277,7 @@ async function resolveMentions( async function sendMessage(args: { accessToken: string content: string + contentType: MicrosoftTeamsWriteChatInput['contentType'] files: MicrosoftTeamsWriteChatInput['files'] messagePath: string membersPath: string @@ -285,6 +286,7 @@ async function sendMessage(args: { enhancedMetadata: (data: MicrosoftTeamsGraphObject) => Record }) { const enhanced = Boolean(args.files?.length || MENTION_PATTERN.test(args.content)) + const requestedContentType: 'text' | 'html' = args.contentType === 'html' ? 'html' : 'text' const client = new MicrosoftTeamsClient(args.accessToken) if (!enhanced) { const data = await client.json( @@ -292,20 +294,23 @@ async function sendMessage(args: { { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ body: { contentType: 'text', content: args.content } }), + body: JSON.stringify({ + body: { contentType: requestedContentType, content: args.content }, + }), }, 'Failed to send Teams message', args.context.signal ) + const metadata = args.plainMetadata(data) return { success: true as const, - output: { updatedContent: true, metadata: args.plainMetadata(data) }, + output: { updatedContent: true, metadata, ...metadata }, } } const uploaded = await uploadFilesForMessage(args.files || [], client, args.context) let messageContent = args.content - let contentType: 'text' | 'html' = 'text' + let contentType: 'text' | 'html' = requestedContentType let mentionResult: MentionResult = { mentions: [], hasMentions: false, @@ -349,13 +354,11 @@ async function sendMessage(args: { 'Failed to send Teams message', args.context.signal ) - const output = { - updatedContent: true, - metadata: { - ...args.enhancedMetadata(data), - attachmentCount: uploaded.attachments.length, - }, + const metadata = { + ...args.enhancedMetadata(data), + attachmentCount: uploaded.attachments.length, } + const output = { updatedContent: true, metadata, ...metadata } if (uploaded.files.length === 0) { return { success: true as const, output: { ...output, files: [] } } } @@ -374,6 +377,7 @@ export async function writeMicrosoftTeamsChatMessage( return sendMessage({ accessToken: input.accessToken, content: input.content, + contentType: input.contentType, files: input.files, messagePath: `/chats/${encodeURIComponent(chatId)}/messages`, membersPath: `/chats/${encodeURIComponent(chatId)}/members`, @@ -405,6 +409,7 @@ export async function writeMicrosoftTeamsChannelMessage( return sendMessage({ accessToken: input.accessToken, content: input.content, + contentType: input.contentType, files: input.files, messagePath: `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages`, membersPath: `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/members`, diff --git a/apps/sim/lib/internal/microsoft-teams/schema.ts b/apps/sim/lib/internal/microsoft-teams/schema.ts index 4f71d68e5df..613f27f6d95 100644 --- a/apps/sim/lib/internal/microsoft-teams/schema.ts +++ b/apps/sim/lib/internal/microsoft-teams/schema.ts @@ -13,6 +13,7 @@ export const microsoftTeamsWriteChatInputSchema = z.object({ chatId: z.string().min(1, 'Chat ID is required'), content: z.string().min(1, 'Message content is required'), files: messageFilesSchema.optional().nullable(), + contentType: z.enum(['text', 'html']).optional().nullable(), }) export const microsoftTeamsWriteChannelInputSchema = z.object({ @@ -21,6 +22,7 @@ export const microsoftTeamsWriteChannelInputSchema = z.object({ channelId: z.string().min(1, 'Channel ID is required'), content: z.string().min(1, 'Message content is required'), files: messageFilesSchema.optional().nullable(), + contentType: z.enum(['text', 'html']).optional().nullable(), }) export type MicrosoftTeamsWriteChatInput = z.infer diff --git a/apps/sim/lib/internal/microsoft-word/client.ts b/apps/sim/lib/internal/microsoft-word/client.ts index b3948e9f26e..3e880897737 100644 --- a/apps/sim/lib/internal/microsoft-word/client.ts +++ b/apps/sim/lib/internal/microsoft-word/client.ts @@ -6,23 +6,10 @@ import { import { DOCX_MIME_TYPE } from '@/lib/microsoft-word/document.server' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import { parseGraphErrorMessage } from '@/tools/microsoft_excel/utils' -import type { MicrosoftWordDocumentMetadata } from '@/tools/microsoft_word/types' - -/** Microsoft Graph `driveItem` fields the Word routes project. */ -interface GraphDriveItem { - id?: string - name?: string - size?: number - webUrl?: string - createdDateTime?: string - lastModifiedDateTime?: string - /** An eTag for the item's content, unchanged when only metadata changes. */ - cTag?: string - /** An eTag for the whole item, metadata included. */ - eTag?: string - file?: { mimeType?: string } - folder?: Record -} +import type { MicrosoftWordGraphDriveItem as GraphDriveItem } from '@/tools/microsoft_word/types' + +/** Byte cap for Graph JSON metadata and error bodies; document bytes use `MAX_FILE_SIZE`. */ +const MAX_GRAPH_JSON_BYTES = 2 * 1024 * 1024 /** * The token that identifies the exact content an edit was based on. @@ -81,22 +68,6 @@ async function raiseGraphError(response: { ) } -/** Projects a Graph `driveItem` onto the metadata shape the Word tools return. */ -export function toDocumentMetadata( - item: GraphDriveItem, - fallbackId: string -): MicrosoftWordDocumentMetadata { - return { - documentId: item.id ?? fallbackId, - name: item.name ?? null, - mimeType: item.file?.mimeType ?? null, - webViewLink: item.webUrl ?? null, - size: item.size ?? null, - createdTime: item.createdDateTime ?? null, - modifiedTime: item.lastModifiedDateTime ?? null, - } -} - /** * Fetches a drive item's metadata and rejects folders, which have no document * content and would otherwise fail later with an opaque Graph error. @@ -110,6 +81,7 @@ export async function fetchDocumentItem( ): Promise { const response = await graphFetch(basePath, 'documentUrl', { headers: { Authorization: `Bearer ${accessToken}` }, + maxResponseBytes: MAX_GRAPH_JSON_BYTES, signal, }) @@ -270,6 +242,7 @@ export async function replaceContentIfUnchanged( 'if-match': expectedTag, }, body: '{}', + maxResponseBytes: MAX_GRAPH_JSON_BYTES, signal, } ) @@ -330,6 +303,7 @@ async function uploadSessionBytes( 'Content-Range': `bytes ${start}-${end}/${total}`, }, body: fragment, + maxResponseBytes: MAX_GRAPH_JSON_BYTES, signal, }, // The upload URL is named by a Graph response rather than configured, so @@ -375,6 +349,7 @@ export async function uploadDocumentContent( 'Content-Length': String(content.length), }, body: content, + maxResponseBytes: MAX_GRAPH_JSON_BYTES, signal, }) diff --git a/apps/sim/lib/internal/microsoft-word/operations.ts b/apps/sim/lib/internal/microsoft-word/operations.ts index 82999611f04..10bb309a626 100644 --- a/apps/sim/lib/internal/microsoft-word/operations.ts +++ b/apps/sim/lib/internal/microsoft-word/operations.ts @@ -5,7 +5,6 @@ import { fetchDocumentItem, replaceContentIfUnchanged, requireContentTag, - toDocumentMetadata, uploadDocumentContent, } from '@/lib/internal/microsoft-word/client' import type { @@ -32,6 +31,7 @@ import { getDocumentBasePath, getDriveBasePath, getFolderBasePath, + toDocumentMetadata, } from '@/tools/microsoft_word/utils' const logger = createLogger('MicrosoftWordOperations') diff --git a/apps/sim/lib/internal/mssql/schema.ts b/apps/sim/lib/internal/mssql/schema.ts index ee10ba4a5ad..06a22c76908 100644 --- a/apps/sim/lib/internal/mssql/schema.ts +++ b/apps/sim/lib/internal/mssql/schema.ts @@ -2,6 +2,10 @@ import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' export const mssqlToggleSchema = z.enum(['enabled', 'disabled']) +/** An on/off SQL Server connection setting, used for `encrypt` and `trustServerCertificate`. */ +export type MssqlToggle = z.output + +export const MSSQL_DEFAULT_CONNECTION_TIMEOUT_MS = 15_000 const nonEmptyRecordSchema = (message: string) => z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, { message }) @@ -52,7 +56,7 @@ export const mssqlConnectionInputSchema = z.object({ .int() .min(1000, 'connectionTimeout must be at least 1000 ms') .max(120000, 'connectionTimeout must be at most 120000 ms') - .default(15000), + .default(MSSQL_DEFAULT_CONNECTION_TIMEOUT_MS), }) export const mssqlQueryInputSchema = mssqlConnectionInputSchema.extend({ diff --git a/apps/sim/lib/internal/mysql/execute-tool.ts b/apps/sim/lib/internal/mysql/execute-tool.ts index dd454d7847a..e786eadb9ec 100644 --- a/apps/sim/lib/internal/mysql/execute-tool.ts +++ b/apps/sim/lib/internal/mysql/execute-tool.ts @@ -10,10 +10,10 @@ import { MysqlOperationInputError, } from '@/lib/internal/mysql/operations' import { + mysqlConnectionInputSchema, mysqlDeleteInputSchema, mysqlExecuteInputSchema, mysqlInsertInputSchema, - mysqlIntrospectInputSchema, mysqlQueryInputSchema, mysqlUpdateInputSchema, } from '@/lib/internal/mysql/schema' @@ -96,7 +96,7 @@ export const executeMysqlTool: InternalToolOperationHandler = async ({ toolId, i ) case 'mysql_introspect': return executeOperation( - mysqlIntrospectInputSchema, + mysqlConnectionInputSchema, input, executeMysqlIntrospection, 'MySQL introspection failed', diff --git a/apps/sim/lib/internal/mysql/schema.ts b/apps/sim/lib/internal/mysql/schema.ts index 3b205709e33..13e63f7d3db 100644 --- a/apps/sim/lib/internal/mysql/schema.ts +++ b/apps/sim/lib/internal/mysql/schema.ts @@ -2,6 +2,11 @@ import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' const sslModeSchema = z.enum(['disabled', 'required', 'preferred']).default('preferred') +/** + * TLS mode for a MySQL connection: `required` encrypts and verifies the server certificate, + * `preferred` encrypts without verifying it, and `disabled` connects without TLS. + */ +export type MysqlSslMode = z.output const nonEmptyRecordSchema = (message: string) => z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, { message }) @@ -25,7 +30,8 @@ const jsonObjectStringSchema = (message: string, includeReceivedValue = false) = } }) -const connectionInputSchema = z.object({ +/** The connection fields every MySQL operation takes; a saved connection stores the same fields. */ +export const mysqlConnectionInputSchema = z.object({ host: z.string().min(1, 'Host is required'), port: z.coerce.number().int().positive('Port must be a positive integer'), database: z.string().min(1, 'Database name is required'), @@ -34,7 +40,7 @@ const connectionInputSchema = z.object({ ssl: sslModeSchema, }) -const queryInputSchema = connectionInputSchema.extend({ +const queryInputSchema = mysqlConnectionInputSchema.extend({ query: z.string().min(1, 'Query is required'), }) const insertDataSchema = z.union([ @@ -48,24 +54,24 @@ const updateDataSchema = z.union([ export const mysqlQueryInputSchema = queryInputSchema export const mysqlExecuteInputSchema = queryInputSchema -export const mysqlInsertInputSchema = connectionInputSchema.extend({ +export const mysqlInsertInputSchema = mysqlConnectionInputSchema.extend({ table: z.string().min(1, 'Table name is required'), data: insertDataSchema, }) -export const mysqlUpdateInputSchema = connectionInputSchema.extend({ +export const mysqlUpdateInputSchema = mysqlConnectionInputSchema.extend({ table: z.string().min(1, 'Table name is required'), data: updateDataSchema, where: z.string().min(1, 'WHERE clause is required'), }) -export const mysqlDeleteInputSchema = connectionInputSchema.extend({ +export const mysqlDeleteInputSchema = mysqlConnectionInputSchema.extend({ table: z.string().min(1, 'Table name is required'), where: z.string().min(1, 'WHERE clause is required'), }) -export const mysqlIntrospectInputSchema = connectionInputSchema export type MysqlQueryInput = z.output export type MysqlExecuteInput = z.output export type MysqlInsertInput = z.output export type MysqlUpdateInput = z.output export type MysqlDeleteInput = z.output -export type MysqlIntrospectInput = z.output +/** Introspection takes only a connection. */ +export type MysqlIntrospectInput = z.output diff --git a/apps/sim/lib/internal/onedrive/operations.ts b/apps/sim/lib/internal/onedrive/operations.ts index 087bdc129a5..71b0e85d18f 100644 --- a/apps/sim/lib/internal/onedrive/operations.ts +++ b/apps/sim/lib/internal/onedrive/operations.ts @@ -26,8 +26,12 @@ import { getExtensionFromMimeType, processSingleFileToUserFile } from '@/lib/upl import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file.server' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import { assertToolFileAccess } from '@/app/api/files/authorization' -import type { OneDriveToolParams } from '@/tools/onedrive/types' -import { normalizeExcelValues } from '@/tools/onedrive/utils' +import type { + MicrosoftGraphDriveItem, + MicrosoftGraphIdentitySet, + OneDriveToolParams, +} from '@/tools/onedrive/types' +import { driveItemDetails, normalizeExcelValues } from '@/tools/onedrive/utils' const MAX_GRAPH_JSON_BYTES = 2 * 1024 * 1024 const MAX_SIMPLE_UPLOAD_BYTES = 250 * 1024 * 1024 @@ -67,6 +71,11 @@ interface OneDriveFileData extends Record { file?: { mimeType?: string } parentReference?: { id: string; path: string } '@microsoft.graph.downloadUrl'?: string + folder?: MicrosoftGraphDriveItem['folder'] + description?: string + eTag?: string + createdBy?: MicrosoftGraphIdentitySet + lastModifiedBy?: MicrosoftGraphIdentitySet } interface ExcelWriteResult { @@ -128,6 +137,7 @@ function uploadedFileOutput(fileData: OneDriveFileData, mimeType: string) { createdTime: fileData.createdDateTime, modifiedTime: fileData.lastModifiedDateTime, parentReference: fileData.parentReference, + ...driveItemDetails(fileData), } } @@ -379,7 +389,7 @@ export async function uploadOneDriveFile( let uploadUrl = folderId ? `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(folderId)}:/${encodedName}:/content` : `https://graph.microsoft.com/v1.0/me/drive/root:/${encodedName}:/content` - if (isStoredFileMode && input.conflictBehavior) { + if (input.conflictBehavior) { uploadUrl += `?@microsoft.graph.conflictBehavior=${input.conflictBehavior}` } const uploadResponse = await graphRequest( diff --git a/apps/sim/lib/internal/outlook/operations.test.ts b/apps/sim/lib/internal/outlook/operations.test.ts index a2fb269f504..1c8f9f47fb5 100644 --- a/apps/sim/lib/internal/outlook/operations.test.ts +++ b/apps/sim/lib/internal/outlook/operations.test.ts @@ -86,14 +86,16 @@ describe('Outlook operations', () => { mockProcessFilesToUserFiles.mockReturnValue([]) }) - it('preserves reply envelopes and encodes reply message IDs', async () => { + it('sends a reply body only once, since Graph rejects comment plus message.body', async () => { await executeOutlookSend({ ...MAIL_INPUT, replyToMessageId: 'message/1' }, MAIL_CONTEXT) const [path, init] = mocks.empty.mock.calls[0] expect(path).toBe('/me/messages/message%2F1/reply') - expect(JSON.parse(init.body)).toMatchObject({ - comment: 'Message body', - message: { subject: 'Hello' }, + const body = JSON.parse(init.body) + expect(body).not.toHaveProperty('comment') + expect(body.message).toMatchObject({ + subject: 'Hello', + body: { contentType: 'html', content: 'Message body' }, }) }) diff --git a/apps/sim/lib/internal/outlook/operations.ts b/apps/sim/lib/internal/outlook/operations.ts index 9dcf9bc3c2f..9ecfb72b905 100644 --- a/apps/sim/lib/internal/outlook/operations.ts +++ b/apps/sim/lib/internal/outlook/operations.ts @@ -49,6 +49,8 @@ interface OutlookMessagePayload { toRecipients: OutlookRecipient[] ccRecipients?: OutlookRecipient[] bccRecipients?: OutlookRecipient[] + replyTo?: OutlookRecipient[] + importance?: 'low' | 'normal' | 'high' attachments?: OutlookFileAttachment[] } @@ -148,6 +150,8 @@ async function buildMessage( } if (input.cc) message.ccRecipients = recipients(input.cc) if (input.bcc) message.bccRecipients = recipients(input.bcc) + if (input.replyTo) message.replyTo = recipients(input.replyTo) + if (input.importance) message.importance = input.importance const attachments = await resolveAttachments(input.attachments, context, kind) if (attachments.length > 0) message.attachments = attachments return message @@ -334,9 +338,7 @@ export async function executeOutlookSend( { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( - replyToMessageId ? { comment: input.body, message } : { message, saveToSentItems: true } - ), + body: JSON.stringify(replyToMessageId ? { message } : { message, saveToSentItems: true }), }, 'Failed to send email', context.signal diff --git a/apps/sim/lib/internal/postgresql/schema.ts b/apps/sim/lib/internal/postgresql/schema.ts index 00dbe358aa6..bd746b30cbe 100644 --- a/apps/sim/lib/internal/postgresql/schema.ts +++ b/apps/sim/lib/internal/postgresql/schema.ts @@ -2,6 +2,11 @@ import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' const sslModeSchema = z.enum(['disabled', 'required', 'preferred']).default('preferred') +/** + * TLS mode for a PostgreSQL connection: `required` encrypts without verifying the server + * certificate, `preferred` uses TLS when the server offers it, and `disabled` connects without TLS. + */ +export type PostgresqlSslMode = z.output const nonEmptyRecordSchema = (message: string) => z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, { message }) @@ -25,7 +30,8 @@ const jsonObjectStringSchema = (message: string, includeReceivedValue = false) = } }) -const connectionInputSchema = z.object({ +/** The connection fields every PostgreSQL operation takes; a saved connection stores the same fields. */ +export const postgresqlConnectionInputSchema = z.object({ host: z.string().min(1, 'Host is required'), port: z.coerce.number().int().positive('Port must be a positive integer'), database: z.string().min(1, 'Database name is required'), @@ -34,7 +40,7 @@ const connectionInputSchema = z.object({ ssl: sslModeSchema, }) -const queryInputSchema = connectionInputSchema.extend({ +const queryInputSchema = postgresqlConnectionInputSchema.extend({ query: z.string().min(1, 'Query is required'), }) const insertDataSchema = z.union([ @@ -48,20 +54,20 @@ const updateDataSchema = z.union([ export const postgresqlQueryInputSchema = queryInputSchema export const postgresqlExecuteInputSchema = queryInputSchema -export const postgresqlInsertInputSchema = connectionInputSchema.extend({ +export const postgresqlInsertInputSchema = postgresqlConnectionInputSchema.extend({ table: z.string().min(1, 'Table name is required'), data: insertDataSchema, }) -export const postgresqlUpdateInputSchema = connectionInputSchema.extend({ +export const postgresqlUpdateInputSchema = postgresqlConnectionInputSchema.extend({ table: z.string().min(1, 'Table name is required'), data: updateDataSchema, where: z.string().min(1, 'WHERE clause is required'), }) -export const postgresqlDeleteInputSchema = connectionInputSchema.extend({ +export const postgresqlDeleteInputSchema = postgresqlConnectionInputSchema.extend({ table: z.string().min(1, 'Table name is required'), where: z.string().min(1, 'WHERE clause is required'), }) -export const postgresqlIntrospectInputSchema = connectionInputSchema.extend({ +export const postgresqlIntrospectInputSchema = postgresqlConnectionInputSchema.extend({ schema: z.string().default('public'), }) diff --git a/apps/sim/lib/internal/rds/client.test.ts b/apps/sim/lib/internal/rds/client.test.ts index 7f36886b9fc..a64e6772829 100644 --- a/apps/sim/lib/internal/rds/client.test.ts +++ b/apps/sim/lib/internal/rds/client.test.ts @@ -1,6 +1,6 @@ import type { RDSDataClient } from '@aws-sdk/client-rds-data' import { describe, expect, it, vi } from 'vitest' -import { executeStatement } from '@/lib/internal/rds/client' +import { executeDelete, executeStatement } from '@/lib/internal/rds/client' describe('executeStatement', () => { it('preserves null elements in nested array values', async () => { @@ -25,4 +25,37 @@ describe('executeStatement', () => { rowCount: 1, }) }) + + it('counts returned records for a SELECT, which reports numberOfRecordsUpdated as 0', async () => { + const send = vi.fn().mockResolvedValue({ + numberOfRecordsUpdated: 0, + columnMetadata: [{ name: 'id' }], + records: [[{ longValue: 1 }], [{ longValue: 2 }]], + }) + const client = { send } as unknown as RDSDataClient + + const result = await executeStatement(client, 'arn', 'secret', 'db', 'SELECT id FROM t') + expect(result.rowCount).toBe(2) + }) + + it('reports affected rows for a write that returns no records', async () => { + const send = vi.fn().mockResolvedValue({ numberOfRecordsUpdated: 3, generatedFields: [] }) + const client = { send } as unknown as RDSDataClient + + const result = await executeStatement(client, 'arn', 'secret', 'db', 'UPDATE t SET a = 1') + expect(result.rowCount).toBe(3) + }) +}) + +describe('parameter binding', () => { + it('binds quoted column names to placeholders the SQL actually references', async () => { + const send = vi.fn().mockResolvedValue({ numberOfRecordsUpdated: 1 }) + const client = { send } as unknown as RDSDataClient + + await executeDelete(client, 'arn', 'secret', 'db', 'users', { '"id"': 7 }) + + const input = send.mock.calls[0]?.[0].input + expect(input.sql).toBe('DELETE FROM users WHERE id = :id') + expect(input.parameters).toEqual([{ name: 'id', value: { longValue: 7 } }]) + }) }) diff --git a/apps/sim/lib/internal/rds/client.ts b/apps/sim/lib/internal/rds/client.ts index 5739a82988e..caf1bfbb734 100644 --- a/apps/sim/lib/internal/rds/client.ts +++ b/apps/sim/lib/internal/rds/client.ts @@ -4,6 +4,7 @@ import { type Field, RDSDataClient, type SqlParameter, + type TypeHint, } from '@aws-sdk/client-rds-data' import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { RdsConnectionConfig } from '@/tools/rds/types' @@ -22,7 +23,7 @@ export async function executeStatement( sql: string, parameters?: SqlParameter[], signal?: AbortSignal -): Promise<{ rows: Record[]; rowCount: number }> { +): Promise { const command = new ExecuteStatementCommand({ resourceArn, secretArn, @@ -37,10 +38,45 @@ export async function executeStatement( return { rows, - rowCount: response.numberOfRecordsUpdated ?? rows.length, + rowCount: Math.max(rows.length, response.numberOfRecordsUpdated ?? 0), + ...(response.generatedFields && + response.generatedFields.length > 0 && { + generatedFields: response.generatedFields.map(parseFieldValue), + }), } } +interface RdsStatementResult { + rows: Record[] + rowCount: number + generatedFields?: unknown[] +} + +interface RdsNamedParameter { + name: string + value?: unknown + typeHint?: TypeHint +} + +/** + * Builds Data API parameters for `:name` placeholders from either a `{ name: value }` map or a + * list of `{ name, value, typeHint }` entries. A type hint tells the Data API to cast a string + * to DATE, TIME, TIMESTAMP, DECIMAL, JSON or UUID. + */ +export function buildSqlParameters( + parameters: Record | RdsNamedParameter[] | undefined +): SqlParameter[] | undefined { + if (!parameters) return undefined + const entries: RdsNamedParameter[] = Array.isArray(parameters) + ? parameters + : Object.entries(parameters).map(([name, value]) => ({ name, value })) + return entries.map((entry) => ({ + name: entry.name.replace(/^:/, ''), + value: toSqlParameterValue(entry.value), + ...(entry.typeHint && { typeHint: entry.typeHint }), + })) +} + function parseRdsResponse(response: ExecuteStatementCommandOutput): Record[] { if (!response.records || !response.columnMetadata) { return [] @@ -104,6 +140,11 @@ export function sanitizeIdentifier(identifier: string): string { return sanitizeSingleIdentifier(identifier) } +/** Placeholder name for a sanitized (possibly schema-qualified) column identifier. */ +function toParameterName(identifier: string): string { + return identifier.replace(/\./g, '_') +} + function sanitizeSingleIdentifier(identifier: string): string { const cleaned = identifier.replace(/`/g, '').replace(/"/g, '') @@ -153,14 +194,15 @@ export async function executeInsert( table: string, data: Record, signal?: AbortSignal -): Promise<{ rows: Record[]; rowCount: number }> { +): Promise { const sanitizedTable = sanitizeIdentifier(table) const columns = Object.keys(data) const sanitizedColumns = columns.map((col) => sanitizeIdentifier(col)) + const parameterNames = sanitizedColumns.map(toParameterName) - const placeholders = columns.map((col) => `:${col}`) - const parameters: SqlParameter[] = columns.map((col) => ({ - name: col, + const placeholders = parameterNames.map((name) => `:${name}`) + const parameters: SqlParameter[] = columns.map((col, index) => ({ + name: parameterNames[index], value: toSqlParameterValue(data[col]), })) @@ -181,12 +223,17 @@ export async function executeUpdate( data: Record, conditions: Record, signal?: AbortSignal -): Promise<{ rows: Record[]; rowCount: number }> { +): Promise { const sanitizedTable = sanitizeIdentifier(table) // Build SET clause with parameters const dataColumns = Object.keys(data) - const setClause = dataColumns.map((col) => `${sanitizeIdentifier(col)} = :set_${col}`).join(', ') + const setClause = dataColumns + .map((col) => { + const identifier = sanitizeIdentifier(col) + return `${identifier} = :set_${toParameterName(identifier)}` + }) + .join(', ') // Build WHERE clause with parameters const conditionColumns = Object.keys(conditions) @@ -194,17 +241,20 @@ export async function executeUpdate( throw new Error('At least one condition is required for UPDATE operations') } const whereClause = conditionColumns - .map((col) => `${sanitizeIdentifier(col)} = :where_${col}`) + .map((col) => { + const identifier = sanitizeIdentifier(col) + return `${identifier} = :where_${toParameterName(identifier)}` + }) .join(' AND ') // Build parameters array (prefixed to avoid name collisions) const parameters: SqlParameter[] = [ ...dataColumns.map((col) => ({ - name: `set_${col}`, + name: `set_${toParameterName(sanitizeIdentifier(col))}`, value: toSqlParameterValue(data[col]), })), ...conditionColumns.map((col) => ({ - name: `where_${col}`, + name: `where_${toParameterName(sanitizeIdentifier(col))}`, value: toSqlParameterValue(conditions[col]), })), ] @@ -225,7 +275,7 @@ export async function executeDelete( table: string, conditions: Record, signal?: AbortSignal -): Promise<{ rows: Record[]; rowCount: number }> { +): Promise { const sanitizedTable = sanitizeIdentifier(table) // Build WHERE clause with parameters @@ -234,11 +284,14 @@ export async function executeDelete( throw new Error('At least one condition is required for DELETE operations') } const whereClause = conditionColumns - .map((col) => `${sanitizeIdentifier(col)} = :${col}`) + .map((col) => { + const identifier = sanitizeIdentifier(col) + return `${identifier} = :${toParameterName(identifier)}` + }) .join(' AND ') const parameters: SqlParameter[] = conditionColumns.map((col) => ({ - name: col, + name: toParameterName(sanitizeIdentifier(col)), value: toSqlParameterValue(conditions[col]), })) diff --git a/apps/sim/lib/internal/rds/operations.ts b/apps/sim/lib/internal/rds/operations.ts index b4214d7a2ab..198cd5fbe58 100644 --- a/apps/sim/lib/internal/rds/operations.ts +++ b/apps/sim/lib/internal/rds/operations.ts @@ -1,5 +1,6 @@ import type { RDSDataClient } from '@aws-sdk/client-rds-data' import { + buildSqlParameters, createRdsClient, executeDelete, executeInsert, @@ -45,7 +46,7 @@ export function executeRdsQuery(input: RdsQueryInput, signal?: AbortSignal) { input.secretArn, input.database, input.query, - undefined, + buildSqlParameters(input.parameters), signal ) return { @@ -64,13 +65,14 @@ export function executeRdsStatement(input: RdsExecuteInput, signal?: AbortSignal input.secretArn, input.database, input.query, - undefined, + buildSqlParameters(input.parameters), signal ) return { message: `Query executed successfully. ${result.rowCount} row(s) affected.`, rows: result.rows, rowCount: result.rowCount, + ...(result.generatedFields && { generatedFields: result.generatedFields }), } }) } @@ -90,6 +92,7 @@ export function executeRdsInsert(input: RdsInsertInput, signal?: AbortSignal) { message: `Insert executed successfully. ${result.rowCount} row(s) inserted.`, rows: result.rows, rowCount: result.rowCount, + ...(result.generatedFields && { generatedFields: result.generatedFields }), } }) } diff --git a/apps/sim/lib/internal/rds/schema.ts b/apps/sim/lib/internal/rds/schema.ts index 6c8574377b6..1a9455f1d35 100644 --- a/apps/sim/lib/internal/rds/schema.ts +++ b/apps/sim/lib/internal/rds/schema.ts @@ -12,8 +12,22 @@ const rdsConnectionInputSchema = z.object({ database: z.string().optional(), }) +const rdsStatementParametersSchema = z + .union([ + z.record(z.string(), z.unknown()), + z.array( + z.object({ + name: z.string().min(1, 'Parameter name is required'), + value: z.unknown().optional(), + typeHint: z.enum(['DATE', 'DECIMAL', 'JSON', 'TIME', 'TIMESTAMP', 'UUID']).optional(), + }) + ), + ]) + .optional() + export const rdsQueryInputSchema = rdsConnectionInputSchema.extend({ query: z.string().min(1, 'Query is required'), + parameters: rdsStatementParametersSchema, }) export const rdsExecuteInputSchema = rdsQueryInputSchema export const rdsInsertInputSchema = rdsConnectionInputSchema.extend({ diff --git a/apps/sim/lib/internal/s3/operations.ts b/apps/sim/lib/internal/s3/operations.ts index 56a82eb2aff..7bf06dde3cf 100644 --- a/apps/sim/lib/internal/s3/operations.ts +++ b/apps/sim/lib/internal/s3/operations.ts @@ -13,6 +13,8 @@ import { type ObjectCannedACL, PutObjectCommand, type S3Client, + type ServerSideEncryption, + type StorageClass, } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { createLogger } from '@sim/logger' @@ -260,6 +262,8 @@ export async function executeS3ListObjects(input: AwsS3ListObjectsBody, signal?: Prefix: input.prefix || undefined, MaxKeys: input.maxKeys || undefined, ContinuationToken: input.continuationToken || undefined, + Delimiter: input.delimiter || undefined, + StartAfter: input.startAfter || undefined, }), { abortSignal: signal } ) @@ -271,7 +275,11 @@ export async function executeS3ListObjects(input: AwsS3ListObjectsBody, signal?: size: object.Size || 0, lastModified: object.LastModified?.toISOString() || '', etag: object.ETag || '', + storageClass: object.StorageClass ?? null, })), + commonPrefixes: (response.CommonPrefixes ?? []).flatMap((commonPrefix) => + commonPrefix.Prefix ? [commonPrefix.Prefix] : [] + ), isTruncated: response.IsTruncated, nextContinuationToken: response.NextContinuationToken, keyCount: response.KeyCount, @@ -378,6 +386,11 @@ export async function executeS3PutObject(input: AwsS3PutObjectBody, context: S3O Body: uploadBody, ContentType: uploadContentType, ACL: input.acl as ObjectCannedACL | undefined, + Metadata: input.metadata ?? undefined, + StorageClass: (input.storageClass as StorageClass | undefined) || undefined, + ServerSideEncryption: + (input.serverSideEncryption as ServerSideEncryption | undefined) || undefined, + SSEKMSKeyId: input.sseKmsKeyId || undefined, }), { abortSignal: signal } ) @@ -392,6 +405,8 @@ export async function executeS3PutObject(input: AwsS3PutObjectBody, context: S3O location: url, key: input.objectKey, bucket: input.bucketName, + versionId: response.VersionId ?? null, + serverSideEncryption: response.ServerSideEncryption ?? null, }, } }) diff --git a/apps/sim/lib/internal/sailpoint/client.ts b/apps/sim/lib/internal/sailpoint/client.ts index 43d721cbfe5..ec0840142c7 100644 --- a/apps/sim/lib/internal/sailpoint/client.ts +++ b/apps/sim/lib/internal/sailpoint/client.ts @@ -9,6 +9,7 @@ import { DEFAULT_MAX_ERROR_BODY_BYTES, readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' +import { throttledRetryAfterMs } from '@/lib/credentials/token-service-accounts/errors' /** API client credentials typed inline on the legacy block. */ export interface SailPointCredentials { @@ -29,7 +30,9 @@ export type SailPointAuth = SailPointCredentials | SailPointTokenCredentials export class SailPointAuthError extends Error { constructor( message: string, - readonly status: number + readonly status: number, + /** The token endpoint's `Retry-After` on a 429 or 503, in milliseconds. */ + readonly retryAfterMs?: number ) { super(message) this.name = 'SailPointAuthError' @@ -180,8 +183,11 @@ async function readBoundedBody( /** * Exchanges API client credentials for an access token at a tenant's token * endpoint (`client_credentials` grant, credentials in the form body), with - * redirects refused and up to four backoff retries on 429. A refusal throws - * {@link SailPointAuthError} with the provider's status and message. + * redirects refused and up to four backoff retries on 429 (`maxThrottleRetries`). + * A 429 whose wait (its `Retry-After`, or the backoff when it names none) + * exceeds `maxThrottleWaitMs` is not retried. A refusal throws + * {@link SailPointAuthError} with the provider's status, message, and + * `Retry-After`, even when its error body cannot be read. * * @see https://developer.sailpoint.com/docs/api/authentication */ @@ -189,7 +195,11 @@ export async function requestSailPointToken( tokenUrl: string, clientId: string, clientSecret: string, - signal: AbortSignal + signal: AbortSignal, + { + maxThrottleRetries = MAX_FETCH_RETRIES, + maxThrottleWaitMs = Number.POSITIVE_INFINITY, + }: { maxThrottleRetries?: number; maxThrottleWaitMs?: number } = {} ): Promise<{ token: string; expiresInSeconds: number }> { let attempt = 0 @@ -211,24 +221,27 @@ export async function requestSailPointToken( signal, }) - if (response.status === 429 && attempt < MAX_FETCH_RETRIES) { - const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')) + const throttleWaitMs = + response.status === 429 && attempt < maxThrottleRetries + ? backoffWithJitter(attempt + 1, parseRetryAfter(response.headers.get('retry-after'))) + : undefined + if (throttleWaitMs !== undefined && throttleWaitMs <= maxThrottleWaitMs) { await consumeOrCancelBody(response, DEFAULT_MAX_ERROR_BODY_BYTES) attempt += 1 - await interruptibleSleep(backoffWithJitter(attempt, retryAfterMs), signal) + await interruptibleSleep(throttleWaitMs, signal) signal?.throwIfAborted() continue } - const data = await readBoundedBody( - response, - response.ok ? MAX_TOKEN_RESPONSE_BYTES : DEFAULT_MAX_ERROR_BODY_BYTES, - signal - ) + const data = response.ok + ? await readBoundedBody(response, MAX_TOKEN_RESPONSE_BYTES, signal) + : await readBoundedBody(response, DEFAULT_MAX_ERROR_BODY_BYTES, signal).catch(() => null) + signal?.throwIfAborted() if (!response.ok) { throw new SailPointAuthError( getSailPointErrorMessage(data, 'Failed to authenticate with SailPoint'), - response.status + response.status, + throttledRetryAfterMs(response) ) } if (!isRecordLike(data) || typeof data.access_token !== 'string' || !data.access_token) { diff --git a/apps/sim/lib/internal/secrets-manager/client.ts b/apps/sim/lib/internal/secrets-manager/client.ts index b3606ba4294..b56a6591eae 100644 --- a/apps/sim/lib/internal/secrets-manager/client.ts +++ b/apps/sim/lib/internal/secrets-manager/client.ts @@ -1,4 +1,11 @@ -import type { RotationRulesType, SecretListEntry, Tag } from '@aws-sdk/client-secrets-manager' +import type { + Filter, + RotationRulesType, + SecretListEntry, + SortByType, + SortOrderType, + Tag, +} from '@aws-sdk/client-secrets-manager' import { CreateSecretCommand, DeleteSecretCommand, @@ -52,15 +59,12 @@ export async function getSecretValue( const response = await client.send(command, { abortSignal: signal }) - if (!response.SecretString && response.SecretBinary) { - throw new Error( - 'Secret is stored as binary (SecretBinary). This integration only supports string secrets.' - ) - } - return { name: response.Name ?? '', secretValue: response.SecretString ?? '', + secretBinary: response.SecretBinary + ? Buffer.from(response.SecretBinary).toString('base64') + : null, arn: response.ARN ?? '', versionId: response.VersionId ?? '', versionStages: response.VersionStages ?? [], @@ -68,15 +72,29 @@ export async function getSecretValue( } } +interface ListSecretsOptions { + filters?: { key: Filter['Key']; values: string[] }[] | null + sortOrder?: SortOrderType | null + sortBy?: SortByType | null + includePlannedDeletion?: boolean | null +} + export async function listSecrets( client: SecretsManagerClient, maxResults?: number | null, nextToken?: string | null, - signal?: AbortSignal + signal?: AbortSignal, + options: ListSecretsOptions = {} ) { const command = new ListSecretsCommand({ ...(maxResults ? { MaxResults: maxResults } : {}), ...(nextToken ? { NextToken: nextToken } : {}), + ...(options.filters && options.filters.length > 0 + ? { Filters: options.filters.map((filter) => ({ Key: filter.key, Values: filter.values })) } + : {}), + ...(options.sortOrder ? { SortOrder: options.sortOrder } : {}), + ...(options.sortBy ? { SortBy: options.sortBy } : {}), + ...(options.includePlannedDeletion ? { IncludePlannedDeletion: true } : {}), }) const response = await client.send(command, { abortSignal: signal }) @@ -84,6 +102,10 @@ export async function listSecrets( name: secret.Name ?? '', arn: secret.ARN ?? '', description: secret.Description ?? null, + kmsKeyId: secret.KmsKeyId ?? null, + rotationLambdaARN: secret.RotationLambdaARN ?? null, + owningService: secret.OwningService ?? null, + primaryRegion: secret.PrimaryRegion ?? null, createdDate: secret.CreatedDate?.toISOString() ?? null, lastChangedDate: secret.LastChangedDate?.toISOString() ?? null, lastAccessedDate: secret.LastAccessedDate?.toISOString() ?? null, @@ -108,12 +130,15 @@ export async function createSecret( name: string, secretValue: string, description?: string | null, - signal?: AbortSignal + signal?: AbortSignal, + options: { kmsKeyId?: string | null; tags?: Tag[] | null } = {} ) { const command = new CreateSecretCommand({ Name: name, SecretString: secretValue, ...(description ? { Description: description } : {}), + ...(options.kmsKeyId ? { KmsKeyId: options.kmsKeyId } : {}), + ...(options.tags && options.tags.length > 0 ? { Tags: options.tags } : {}), }) const response = await client.send(command, { abortSignal: signal }) @@ -129,12 +154,14 @@ export async function updateSecretValue( secretId: string, secretValue: string, description?: string | null, - signal?: AbortSignal + signal?: AbortSignal, + kmsKeyId?: string | null ) { const command = new UpdateSecretCommand({ SecretId: secretId, SecretString: secretValue, ...(description ? { Description: description } : {}), + ...(kmsKeyId ? { KmsKeyId: kmsKeyId } : {}), }) const response = await client.send(command, { abortSignal: signal }) diff --git a/apps/sim/lib/internal/secrets-manager/operations.ts b/apps/sim/lib/internal/secrets-manager/operations.ts index fc428db3d0f..97f9777aa86 100644 --- a/apps/sim/lib/internal/secrets-manager/operations.ts +++ b/apps/sim/lib/internal/secrets-manager/operations.ts @@ -40,7 +40,12 @@ export async function executeSecretsManagerListSecrets( ) { const client = createSecretsManagerClient(input) try { - return await listSecrets(client, input.maxResults, input.nextToken, signal) + return await listSecrets(client, input.maxResults, input.nextToken, signal, { + filters: input.filters, + sortOrder: input.sortOrder, + sortBy: input.sortBy, + includePlannedDeletion: input.includePlannedDeletion, + }) } finally { client.destroy() } @@ -57,7 +62,11 @@ export async function executeSecretsManagerCreateSecret( input.name, input.secretValue, input.description, - signal + signal, + { + kmsKeyId: input.kmsKeyId, + tags: input.tags?.map((tag) => ({ Key: tag.key, Value: tag.value })), + } ) return { message: `Secret "${result.name}" created successfully`, ...result } } finally { @@ -76,7 +85,8 @@ export async function executeSecretsManagerUpdateSecret( input.secretId, input.secretValue, input.description, - signal + signal, + input.kmsKeyId ) return { message: `Secret "${result.name}" updated successfully`, ...result } } finally { diff --git a/apps/sim/lib/internal/sendgrid/client.test.ts b/apps/sim/lib/internal/sendgrid/client.test.ts new file mode 100644 index 00000000000..c85ba6e6d13 --- /dev/null +++ b/apps/sim/lib/internal/sendgrid/client.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sendSendGridMail } from '@/lib/internal/sendgrid/client' + +describe('SendGrid mail send', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock + .mockReset() + .mockResolvedValue(new Response(null, { status: 202, headers: { 'X-Message-Id': 'm-1' } })) + vi.stubGlobal('fetch', fetchMock) + }) + + it.each([ + [undefined, 'https://api.sendgrid.com/v3/mail/send'], + ['global', 'https://api.sendgrid.com/v3/mail/send'], + ['eu', 'https://api.eu.sendgrid.com/v3/mail/send'], + ])('sends mail for a %s key through %s', async (region, url) => { + await expect(sendSendGridMail('key', region, { subject: 'Hi' })).resolves.toBe('m-1') + expect(fetchMock.mock.calls[0][0]).toBe(url) + }) + + it('refuses a region it does not know without sending the key', async () => { + await expect(sendSendGridMail('key', 'mars', { subject: 'Hi' })).rejects.toThrow() + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/sendgrid/client.ts b/apps/sim/lib/internal/sendgrid/client.ts index d81bffe06a6..7075e1d41a2 100644 --- a/apps/sim/lib/internal/sendgrid/client.ts +++ b/apps/sim/lib/internal/sendgrid/client.ts @@ -1,6 +1,7 @@ import { toRecord } from '@sim/utils/object' import { consumeOrCancelBody, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { SendGridOperationError } from '@/lib/internal/sendgrid/errors' +import { SENDGRID_REGIONS } from '@/tools/sendgrid/utils' const MAX_SENDGRID_ERROR_BYTES = 64 * 1024 @@ -17,13 +18,15 @@ function errorMessage(value: unknown): string { return 'Failed to send email' } +/** Sends one mail with a SendGrid key through the API host of its region (global or eu). */ export async function sendSendGridMail( apiKey: string, + region: string | undefined, body: Record, signal?: AbortSignal ): Promise { signal?.throwIfAborted() - const response = await fetch('https://api.sendgrid.com/v3/mail/send', { + const response = await fetch(`${SENDGRID_REGIONS.apiBase(region)}/mail/send`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, diff --git a/apps/sim/lib/internal/sendgrid/operations.ts b/apps/sim/lib/internal/sendgrid/operations.ts index 2db21669dd6..a223cb6cc13 100644 --- a/apps/sim/lib/internal/sendgrid/operations.ts +++ b/apps/sim/lib/internal/sendgrid/operations.ts @@ -95,7 +95,7 @@ export async function executeSendGridSend( } } - const messageId = await sendSendGridMail(input.apiKey, body, context.signal) + const messageId = await sendSendGridMail(input.apiKey, input.region, body, context.signal) return { success: true, output: { diff --git a/apps/sim/lib/internal/sendgrid/schema.ts b/apps/sim/lib/internal/sendgrid/schema.ts index ff42e97a84c..0bd720a2486 100644 --- a/apps/sim/lib/internal/sendgrid/schema.ts +++ b/apps/sim/lib/internal/sendgrid/schema.ts @@ -2,9 +2,14 @@ import { z } from 'zod' import { resolvedSecretTraceProvenanceSchema } from '@/lib/api/contracts/primitives' import { RESOLVED_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' +import { SENDGRID_REGIONS } from '@/tools/sendgrid/utils' export const sendGridSendInputSchema = z.object({ apiKey: z.string().min(1, 'API key is required'), + region: z + .string() + .refine((value) => SENDGRID_REGIONS.has(value), 'Unknown SendGrid region') + .optional(), from: z.string().min(1, 'From email is required'), fromName: z.string().optional().nullable(), to: z.string().min(1, 'To email is required'), diff --git a/apps/sim/lib/internal/servicenow/client.test.ts b/apps/sim/lib/internal/servicenow/client.test.ts new file mode 100644 index 00000000000..35da444ac5d --- /dev/null +++ b/apps/sim/lib/internal/servicenow/client.test.ts @@ -0,0 +1,63 @@ +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { uploadServiceNowAttachment } from '@/lib/internal/servicenow/client' +import { serviceNowUploadAttachmentInputSchema } from '@/lib/internal/servicenow/schema' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation + +const UPLOAD = { + contentType: 'text/plain', + fileName: 'notes.txt', + instanceUrl: 'https://acme.service-now.com', + recordSysId: 'rec-1', + tableName: 'incident', +} + +function sentAuthorization(): unknown { + return secureFetch.mock.lastCall?.[1]?.headers?.Authorization +} + +describe('ServiceNow attachment upload sign-in', () => { + beforeEach(() => { + secureFetch.mockReset().mockResolvedValue(Response.json({ result: { sys_id: 'att-1' } })) + }) + + it('sends a saved OAuth client token as a bearer token without a username', async () => { + const input = serviceNowUploadAttachmentInputSchema.parse({ + ...UPLOAD, + password: 'minted-token', + accessToken: 'minted-token', + authStyle: 'bearer', + }) + + await uploadServiceNowAttachment({ ...UPLOAD, ...input }, Buffer.from('hi')) + + expect(sentAuthorization()).toBe('Bearer minted-token') + }) + + it('signs a login in with Basic exactly as before', async () => { + const input = serviceNowUploadAttachmentInputSchema.parse({ + ...UPLOAD, + username: 'integration.user', + password: 'saved-password', + }) + + await uploadServiceNowAttachment({ ...UPLOAD, ...input }, Buffer.from('hi')) + + expect(sentAuthorization()).toBe( + `Basic ${Buffer.from('integration.user:saved-password').toString('base64')}` + ) + }) + + it('still requires a username for a login', () => { + expect( + serviceNowUploadAttachmentInputSchema.safeParse({ ...UPLOAD, password: 'saved-password' }) + .success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/internal/servicenow/client.ts b/apps/sim/lib/internal/servicenow/client.ts index 7e5c45bb44e..7d2b257611d 100644 --- a/apps/sim/lib/internal/servicenow/client.ts +++ b/apps/sim/lib/internal/servicenow/client.ts @@ -4,18 +4,16 @@ import { secureFetchWithValidation, } from '@/lib/core/security/input-validation.server' import { ServiceNowOperationError } from '@/lib/internal/servicenow/errors' -import type { ServiceNowAttachment } from '@/tools/servicenow/types' -import { createBasicAuthHeader } from '@/tools/servicenow/utils' +import type { ServiceNowAttachment, ServiceNowAuthParams } from '@/tools/servicenow/types' +import { serviceNowAuthorization } from '@/tools/servicenow/utils' export async function uploadServiceNowAttachment( - input: { + input: Omit & { + username?: string contentType: string fileName: string - instanceUrl: string - password: string recordSysId: string tableName: string - username: string }, buffer: Buffer, signal?: AbortSignal @@ -33,7 +31,7 @@ export async function uploadServiceNowAttachment( profile: 'configuredEndpoint', method: 'POST', headers: { - Authorization: createBasicAuthHeader(input.username, input.password), + Authorization: serviceNowAuthorization(input), 'Content-Type': input.contentType, Accept: 'application/json', }, diff --git a/apps/sim/lib/internal/servicenow/operations.ts b/apps/sim/lib/internal/servicenow/operations.ts index 455ea2493e9..6c3ae0ee342 100644 --- a/apps/sim/lib/internal/servicenow/operations.ts +++ b/apps/sim/lib/internal/servicenow/operations.ts @@ -68,6 +68,8 @@ export async function executeServiceNowUploadAttachment( recordSysId: input.recordSysId, tableName: input.tableName, username: input.username, + accessToken: input.accessToken, + authStyle: input.authStyle, }, buffer, context.signal diff --git a/apps/sim/lib/internal/servicenow/schema.ts b/apps/sim/lib/internal/servicenow/schema.ts index 16b6a555ae4..269d40881a3 100644 --- a/apps/sim/lib/internal/servicenow/schema.ts +++ b/apps/sim/lib/internal/servicenow/schema.ts @@ -1,14 +1,35 @@ import { z } from 'zod' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' +import { SERVICENOW_BEARER_AUTH_STYLE } from '@/tools/servicenow/utils' -export const serviceNowUploadAttachmentInputSchema = z.object({ - instanceUrl: z.string().min(1, 'Instance URL is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - tableName: z.string().min(1, 'Table name is required'), - recordSysId: z.string().min(1, 'Record sys_id is required'), - fileName: z.string().min(1, 'File name is required'), - file: RawFileInputSchema.optional().nullable(), -}) +/** + * A Basic login needs a username; a saved OAuth client credential instead sends the token it + * minted (`accessToken`, with `authStyle: 'bearer'`) and has no username. + */ +export const serviceNowUploadAttachmentInputSchema = z + .object({ + instanceUrl: z.string().min(1, 'Instance URL is required'), + username: z.string().optional(), + password: z.string().min(1, 'Password is required'), + accessToken: z.string().optional(), + authStyle: z.literal(SERVICENOW_BEARER_AUTH_STYLE).optional(), + tableName: z.string().min(1, 'Table name is required'), + recordSysId: z.string().min(1, 'Record sys_id is required'), + fileName: z.string().min(1, 'File name is required'), + file: RawFileInputSchema.optional().nullable(), + }) + .superRefine((input, context) => { + if (input.authStyle === SERVICENOW_BEARER_AUTH_STYLE) { + if (!input.accessToken) { + context.addIssue({ + code: 'custom', + path: ['accessToken'], + message: 'Access token is required', + }) + } + } else if (!input.username) { + context.addIssue({ code: 'custom', path: ['username'], message: 'Username is required' }) + } + }) export type ServiceNowUploadAttachmentInput = z.output diff --git a/apps/sim/lib/internal/ses/client.ts b/apps/sim/lib/internal/ses/client.ts index f5de62a80fa..783b5b994f8 100644 --- a/apps/sim/lib/internal/ses/client.ts +++ b/apps/sim/lib/internal/ses/client.ts @@ -25,13 +25,64 @@ import { z } from 'zod' import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { SESConnectionConfig } from '@/tools/ses/types' +/** + * Optional per-destination Cc/Bcc. A malformed value is dropped rather than failing + * the send, matching how these keys were ignored before they were supported. + */ +const OptionalAddressListSchema = z.array(z.string().email()).optional().catch(undefined) + const SesBulkEmailDestinationSchema = z.object({ toAddresses: z.array(z.string().email()), + ccAddresses: OptionalAddressListSchema, + bccAddresses: OptionalAddressListSchema, templateData: z.string().optional(), }) type SesBulkEmailDestination = z.infer +/** A `MessageTag` applied to a send, for filtering its sending events. */ +interface SesEmailTag { + name: string + value: string +} + +/** + * The `FromEmailAddress` value with an optional friendly name. AWS requires a + * non-ASCII friendly name in MIME encoded-word syntax; an ASCII one is quoted. + */ +function formatFromAddress(address: string, name?: string | null): string { + const friendlyName = name?.replace(/[\r\n]+/g, ' ').trim() + if (!friendlyName) return address + if (/^[\x20-\x7e]*$/.test(friendlyName)) { + return `"${friendlyName.replace(/(["\\])/g, '\\$1')}" <${address}>` + } + return `${encodeMimeWords(friendlyName)} <${address}>` +} + +/** Bytes per encoded word, so `=?utf-8?B?…?=` stays within RFC 2047's 75 characters. */ +const MIME_WORD_MAX_BYTES = 45 + +/** Encodes text as RFC 2047 B-encoded words, splitting only between characters. */ +function encodeMimeWords(text: string): string { + const words: string[] = [] + let chunk = '' + for (const char of text) { + if (Buffer.byteLength(chunk + char, 'utf8') > MIME_WORD_MAX_BYTES) { + words.push(chunk) + chunk = '' + } + chunk += char + } + if (chunk) words.push(chunk) + return words + .map((word) => `=?utf-8?B?${Buffer.from(word, 'utf8').toString('base64')}?=`) + .join(' ') +} + +function toMessageTags(tags?: SesEmailTag[] | null) { + return tags?.length ? tags.map((tag) => ({ Name: tag.name, Value: tag.value })) : undefined +} + export function createSESClient(config: SESConnectionConfig): SESv2Client { return new SESv2Client({ ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), @@ -50,11 +101,14 @@ export async function sendEmail( bccAddresses?: string[] | null replyToAddresses?: string[] | null configurationSetName?: string | null + fromName?: string | null + emailTags?: SesEmailTag[] | null }, signal?: AbortSignal ) { + const emailTags = toMessageTags(params.emailTags) const command = new SendEmailCommand({ - FromEmailAddress: params.fromAddress, + FromEmailAddress: formatFromAddress(params.fromAddress, params.fromName), Destination: { ToAddresses: params.toAddresses, ...(params.ccAddresses?.length ? { CcAddresses: params.ccAddresses } : {}), @@ -71,6 +125,7 @@ export async function sendEmail( }, ...(params.replyToAddresses?.length ? { ReplyToAddresses: params.replyToAddresses } : {}), ...(params.configurationSetName ? { ConfigurationSetName: params.configurationSetName } : {}), + ...(emailTags ? { EmailTags: emailTags } : {}), }) const response = await client.send(command, { abortSignal: signal }) @@ -89,12 +144,16 @@ export async function sendTemplatedEmail( templateData: string ccAddresses?: string[] | null bccAddresses?: string[] | null + replyToAddresses?: string[] | null configurationSetName?: string | null + fromName?: string | null + emailTags?: SesEmailTag[] | null }, signal?: AbortSignal ) { + const emailTags = toMessageTags(params.emailTags) const command = new SendEmailCommand({ - FromEmailAddress: params.fromAddress, + FromEmailAddress: formatFromAddress(params.fromAddress, params.fromName), Destination: { ToAddresses: params.toAddresses, ...(params.ccAddresses?.length ? { CcAddresses: params.ccAddresses } : {}), @@ -106,7 +165,9 @@ export async function sendTemplatedEmail( TemplateData: params.templateData, }, }, + ...(params.replyToAddresses?.length ? { ReplyToAddresses: params.replyToAddresses } : {}), ...(params.configurationSetName ? { ConfigurationSetName: params.configurationSetName } : {}), + ...(emailTags ? { EmailTags: emailTags } : {}), }) const response = await client.send(command, { abortSignal: signal }) @@ -129,11 +190,15 @@ export async function sendBulkEmail( destinations: SesBulkEmailDestination[] defaultTemplateData?: string | null configurationSetName?: string | null + replyToAddresses?: string[] | null + fromName?: string | null + emailTags?: SesEmailTag[] | null }, signal?: AbortSignal ) { + const emailTags = toMessageTags(params.emailTags) const command = new SendBulkEmailCommand({ - FromEmailAddress: params.fromAddress, + FromEmailAddress: formatFromAddress(params.fromAddress, params.fromName), DefaultContent: { Template: { TemplateName: params.templateName, @@ -141,7 +206,11 @@ export async function sendBulkEmail( }, }, BulkEmailEntries: params.destinations.map((dest) => ({ - Destination: { ToAddresses: dest.toAddresses }, + Destination: { + ToAddresses: dest.toAddresses, + ...(dest.ccAddresses?.length ? { CcAddresses: dest.ccAddresses } : {}), + ...(dest.bccAddresses?.length ? { BccAddresses: dest.bccAddresses } : {}), + }, ...(dest.templateData ? { ReplacementEmailContent: { @@ -152,7 +221,9 @@ export async function sendBulkEmail( } : {}), })), + ...(params.replyToAddresses?.length ? { ReplyToAddresses: params.replyToAddresses } : {}), ...(params.configurationSetName ? { ConfigurationSetName: params.configurationSetName } : {}), + ...(emailTags ? { DefaultEmailTags: emailTags } : {}), }) const response = await client.send(command, { abortSignal: signal }) @@ -207,6 +278,8 @@ export async function getAccount(client: SESv2Client, signal?: AbortSignal) { max24HourSend: response.SendQuota?.Max24HourSend ?? 0, maxSendRate: response.SendQuota?.MaxSendRate ?? 0, sentLast24Hours: response.SendQuota?.SentLast24Hours ?? 0, + productionAccessEnabled: response.ProductionAccessEnabled ?? null, + enforcementStatus: response.EnforcementStatus ?? null, } } diff --git a/apps/sim/lib/internal/ses/operations.test.ts b/apps/sim/lib/internal/ses/operations.test.ts new file mode 100644 index 00000000000..9f12b09398c --- /dev/null +++ b/apps/sim/lib/internal/ses/operations.test.ts @@ -0,0 +1,124 @@ +import { SESv2Client } from '@aws-sdk/client-sesv2' +import { describe, expect, it, vi } from 'vitest' +import { + executeSesSendBulkEmail, + executeSesSendEmail, + executeSesSendTemplatedEmail, +} from '@/lib/internal/ses/operations' + +const CONNECTION = { + region: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'secret', +} + +function captureSentInput() { + const send = vi + .spyOn(SESv2Client.prototype, 'send') + .mockImplementation(async () => ({ MessageId: 'message-id', BulkEmailEntryResults: [] })) + return () => { + const command = send.mock.calls[0]?.[0] + return (command && 'input' in command ? command.input : {}) as Record + } +} + +describe('SES send operations', () => { + it('sends Reply-To on a templated email', async () => { + const sentInput = captureSentInput() + + await executeSesSendTemplatedEmail({ + ...CONNECTION, + fromAddress: 'sender@example.com', + toAddresses: 'to@example.com', + templateName: 'welcome', + templateData: '{}', + replyToAddresses: 'support@example.com, sales@example.com', + }) + + expect(sentInput().ReplyToAddresses).toEqual(['support@example.com', 'sales@example.com']) + }) + + it('keeps per-destination Cc and Bcc on a bulk send', async () => { + const sentInput = captureSentInput() + + await executeSesSendBulkEmail({ + ...CONNECTION, + fromAddress: 'sender@example.com', + templateName: 'welcome', + destinations: JSON.stringify([ + { + toAddresses: ['to@example.com'], + ccAddresses: ['cc@example.com'], + bccAddresses: ['bcc@example.com'], + }, + ]), + }) + + expect(sentInput().BulkEmailEntries).toEqual([ + { + Destination: { + ToAddresses: ['to@example.com'], + CcAddresses: ['cc@example.com'], + BccAddresses: ['bcc@example.com'], + }, + }, + ]) + }) + + it('encodes a non-ASCII friendly From name and quotes an ASCII one', async () => { + const sentInput = captureSentInput() + + await executeSesSendEmail({ + ...CONNECTION, + fromAddress: 'sender@example.com', + fromName: 'Café "Ops"', + toAddresses: 'to@example.com', + subject: 'Hi', + bodyText: 'Hello', + }) + await executeSesSendEmail({ + ...CONNECTION, + fromAddress: 'sender@example.com', + fromName: 'Acme "Ops"', + toAddresses: 'to@example.com', + subject: 'Hi', + bodyText: 'Hello', + }) + + const send = vi.mocked(SESv2Client.prototype.send) + const fromOf = (index: number) => { + const command = send.mock.calls[index]?.[0] + return command && 'input' in command + ? (command.input as { FromEmailAddress?: string }).FromEmailAddress + : undefined + } + expect(sentInput().FromEmailAddress).toBe( + `=?utf-8?B?${Buffer.from('Café "Ops"').toString('base64')}?= ` + ) + expect(fromOf(1)).toBe('"Acme \\"Ops\\"" ') + }) + + it('splits a long non-ASCII From name into encoded words of at most 75 characters', async () => { + const sentInput = captureSentInput() + const name = 'Équipe de support client — Société Générale Ünïcødé Ops' + + await executeSesSendEmail({ + ...CONNECTION, + fromAddress: 'sender@example.com', + fromName: name, + toAddresses: 'to@example.com', + subject: 'Hi', + bodyText: 'Hello', + }) + + const [display, address] = String(sentInput().FromEmailAddress).split(' <') + const words: string[] = display.split(' ') + expect(words.length).toBeGreaterThan(1) + for (const word of words) expect(word.length).toBeLessThanOrEqual(75) + const decoded = words + .map((word) => Buffer.from(word.slice('=?utf-8?B?'.length, -2), 'base64').toString('utf8')) + .join('') + expect(decoded).toBe(name) + expect(address).toBe('sender@example.com>') + }) +}) diff --git a/apps/sim/lib/internal/ses/operations.ts b/apps/sim/lib/internal/ses/operations.ts index b12955770ac..cd30d7e984c 100644 --- a/apps/sim/lib/internal/ses/operations.ts +++ b/apps/sim/lib/internal/ses/operations.ts @@ -81,6 +81,8 @@ export function executeSesSendEmail(input: AwsSesSendEmailBody, signal?: AbortSi bccAddresses: splitEmailAddresses(input.bccAddresses), replyToAddresses: splitEmailAddresses(input.replyToAddresses), configurationSetName: input.configurationSetName, + fromName: input.fromName, + emailTags: input.emailTags, }, signal ) @@ -101,7 +103,10 @@ export function executeSesSendTemplatedEmail( templateData: input.templateData, ccAddresses: splitEmailAddresses(input.ccAddresses), bccAddresses: splitEmailAddresses(input.bccAddresses), + replyToAddresses: splitEmailAddresses(input.replyToAddresses), configurationSetName: input.configurationSetName, + fromName: input.fromName, + emailTags: input.emailTags, }, signal ) @@ -127,6 +132,9 @@ export function executeSesSendBulkEmail(input: AwsSesSendBulkEmailBody, signal?: destinations, defaultTemplateData: input.defaultTemplateData, configurationSetName: input.configurationSetName, + replyToAddresses: splitEmailAddresses(input.replyToAddresses), + fromName: input.fromName, + emailTags: input.emailTags, }, signal ) diff --git a/apps/sim/lib/internal/sharepoint/client.ts b/apps/sim/lib/internal/sharepoint/client.ts index fee7d105641..4e21f1f7e75 100644 --- a/apps/sim/lib/internal/sharepoint/client.ts +++ b/apps/sim/lib/internal/sharepoint/client.ts @@ -1,3 +1,4 @@ +import { getErrorMessage } from '@sim/utils/errors' import { type SecureFetchOptions, type SecureFetchResponse, @@ -11,6 +12,7 @@ import { readResponseToBufferWithLimit, } from '@/lib/core/utils/stream-limits' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { graphPathSegment } from '@/tools/sharepoint/utils' interface GraphErrorBody { error?: { message?: string } @@ -77,6 +79,16 @@ function readJson( }) } +function driveItemUrl(driveId: string, itemId: string): string { + try { + const drive = graphPathSegment(driveId, 'driveId') + const item = graphPathSegment(itemId, 'itemId') + return `https://graph.microsoft.com/v1.0/drives/${drive}/items/${item}` + } catch (error) { + throw new SharePointGraphError(getErrorMessage(error, 'Invalid drive item path'), 400) + } +} + function graphErrorMessage( data: SharePointUploadedItem | GraphErrorBody, fallback: string @@ -114,7 +126,7 @@ export class SharePointClient { } async getMetadata(driveId: string, itemId: string): Promise { - const url = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}` + const url = driveItemUrl(driveId, itemId) const response = await this.pinnedFetch(url, 'metadataUrl', { headers: { Authorization: this.authorization }, }) @@ -134,7 +146,7 @@ export class SharePointClient { } async download(driveId: string, itemId: string): Promise { - const url = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}/content` + const url = `${driveItemUrl(driveId, itemId)}/content` const response = await this.pinnedFetch(url, 'downloadUrl', { headers: { Authorization: this.authorization }, stripAuthOnRedirect: true, diff --git a/apps/sim/lib/internal/sharepoint/operations.test.ts b/apps/sim/lib/internal/sharepoint/operations.test.ts index 2fffa15f38d..641de3a9a20 100644 --- a/apps/sim/lib/internal/sharepoint/operations.test.ts +++ b/apps/sim/lib/internal/sharepoint/operations.test.ts @@ -108,6 +108,21 @@ describe('SharePoint operations', () => { expect(mocks.uploadGraph).not.toHaveBeenCalled() }) + it('refuses a folder path that climbs out of the drive root', async () => { + const response = await executeSharePointUploadFile( + { + accessToken: 'token', + siteId: 'contoso.sharepoint.com,guid-1,guid-2', + folderPath: '/Reports/../../../../me/drive/root', + files: [userFile], + }, + { userId: 'user-1', requestId: 'request-1' } + ) + + expect(response.status).toBe(400) + expect(mocks.uploadGraph).not.toHaveBeenCalled() + }) + it('keeps a large download in process until a stored file can be presented', async () => { const controller = new AbortController() mocks.getMetadata.mockResolvedValue({ diff --git a/apps/sim/lib/internal/sharepoint/operations.ts b/apps/sim/lib/internal/sharepoint/operations.ts index 91bc33648f2..7fa0f5a9a64 100644 --- a/apps/sim/lib/internal/sharepoint/operations.ts +++ b/apps/sim/lib/internal/sharepoint/operations.ts @@ -17,6 +17,8 @@ import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file.server import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' import type { SharepointSkippedFile, SharepointUploadError } from '@/tools/sharepoint/types' +import { graphPathSegment } from '@/tools/sharepoint/utils' +import { assertNoDotPathSegments } from '@/tools/url-path' const logger = createLogger('SharePointOperations') export const MAX_SHAREPOINT_UPLOAD_BYTES = 250 * 1024 * 1024 @@ -87,7 +89,14 @@ function buildUploadUrl( const folderPath = input.folderPath?.trim() || '' const normalizedPath = folderPath.startsWith('/') ? folderPath : `/${folderPath}` const cleanPath = normalizedPath.endsWith('/') ? normalizedPath.slice(0, -1) : normalizedPath - const uploadPath = folderPath ? `${cleanPath}/${fileName}` : `/${fileName}` + // Drop "." folder segments (the URL parser would collapse them); ".." is rejected below + const uploadPath = (folderPath ? `${cleanPath}/${fileName}` : `/${fileName}`) + .split('/') + .filter((segment, index, segments) => segment !== '.' || index === segments.length - 1) + .join('/') + if (uploadPath.split('/').some((segment) => segment.trim() === '.' || segment.trim() === '..')) { + throw new Error('Upload path cannot contain "." or ".." segments') + } const encodedPath = uploadPath .split('/') .map((segment) => (segment ? encodeURIComponent(segment) : '')) @@ -95,8 +104,9 @@ function buildUploadUrl( const driveId = input.driveId?.trim() const siteId = input.siteId.trim() || 'root' const url = driveId - ? `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root:${encodedPath}:/content` - : `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/drive/root:${encodedPath}:/content` + ? `https://graph.microsoft.com/v1.0/drives/${graphPathSegment(driveId, 'driveId')}/root:${encodedPath}:/content` + : `https://graph.microsoft.com/v1.0/sites/${graphPathSegment(siteId, 'siteId')}/drive/root:${encodedPath}:/content` + assertNoDotPathSegments(url) return { url, replaceUrl: `${url}?@microsoft.graph.conflictBehavior=replace` } } @@ -111,6 +121,12 @@ export async function executeSharePointUploadFile( const userFiles = processFilesToUserFiles(input.files, context.requestId, logger) if (userFiles.length === 0) return failureResponse('No valid files to upload', 400) + try { + buildUploadUrl(input, input.fileName || userFiles[0]?.name || 'file') + } catch (error) { + return failureResponse(getErrorMessage(error, 'Invalid upload path'), 400) + } + const client = new SharePointClient(input.accessToken, context.signal) const uploadedFiles: SharePointUploadedItem[] = [] const skippedFiles: SharepointSkippedFile[] = [] @@ -129,6 +145,18 @@ export async function executeSharePointUploadFile( if (denied) return denied const fileName = input.fileName || userFile.name + let uploadUrls: { url: string; replaceUrl: string } + try { + uploadUrls = buildUploadUrl(input, fileName) + } catch (error) { + errors.push({ + name: fileName, + status: 400, + error: getErrorMessage(error, 'Invalid upload path'), + }) + continue + } + const { url, replaceUrl } = uploadUrls const skipOversized = (size: number) => { skippedFiles.push({ name: fileName, @@ -158,7 +186,6 @@ export async function executeSharePointUploadFile( throw error } - const { url, replaceUrl } = buildUploadUrl(input, fileName) const contentType = downloadedContentType || userFile.type || 'application/octet-stream' const uploadResult = await client.upload(url, buffer, contentType) if (!uploadResult.ok) { diff --git a/apps/sim/lib/internal/sqs/operations.test.ts b/apps/sim/lib/internal/sqs/operations.test.ts index f7f5e339c92..8c1b4b0401c 100644 --- a/apps/sim/lib/internal/sqs/operations.test.ts +++ b/apps/sim/lib/internal/sqs/operations.test.ts @@ -1,3 +1,4 @@ +import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreateSqsClient, mockDestroy, mockSend } = vi.hoisted(() => ({ @@ -13,6 +14,7 @@ vi.mock('@/lib/internal/sqs/client', () => ({ import { executeSqsDeleteMessageBatch, executeSqsListDeadLetterSourceQueues, + executeSqsSend, } from '@/lib/internal/sqs/operations' const CONNECTION = { @@ -25,6 +27,7 @@ const QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/test-queue' describe('SQS operations', () => { beforeEach(() => { + envFlagsMock.isHosted = true mockCreateSqsClient.mockReturnValue({ send: mockSend, destroy: mockDestroy }) }) @@ -58,4 +61,50 @@ describe('SQS operations', () => { executeSqsListDeadLetterSourceQueues({ ...CONNECTION, queueUrl: QUEUE_URL }) ).resolves.toEqual({ queueUrls: [QUEUE_URL], nextToken: 'next', count: 1 }) }) + + it.each([ + 'https://attacker.example/123456789012/test-queue', + 'https://ec2-1-2-3-4.compute-1.amazonaws.com/123456789012/test-queue', + 'https://abc123.execute-api.us-east-1.amazonaws.com/123456789012/test-queue', + 'http://sqs.us-east-1.amazonaws.com/123456789012/test-queue', + 'https://sqs.us-east-1.amazonaws.com.evil.example/123456789012/test-queue', + ])('refuses %s, which the SDK would use as the request host', async (queueUrl) => { + mockSend.mockResolvedValue({ MessageId: 'id' }) + + await expect(executeSqsSend({ ...CONNECTION, queueUrl, data: { ok: true } })).rejects.toThrow( + 'Queue URL must be an Amazon SQS queue URL' + ) + expect(mockCreateSqsClient).not.toHaveBeenCalled() + }) + + it('leaves self-hosted SQS-compatible queue URLs alone', async () => { + envFlagsMock.isHosted = false + mockSend.mockResolvedValue({ MessageId: 'id' }) + + await expect( + executeSqsSend({ + ...CONNECTION, + queueUrl: 'http://localhost:4566/000000000000/test-queue', + data: { ok: true }, + }) + ).resolves.toMatchObject({ id: 'id' }) + }) + + it.each([ + QUEUE_URL, + 'https://us-east-1.queue.amazonaws.com/123456789012/test-queue', + 'https://vpce-0abc-1234.sqs.us-east-1.vpce.amazonaws.com/123456789012/test-queue', + 'https://sqs.cn-north-1.amazonaws.com.cn/123456789012/test-queue', + 'https://sqs.us-east-1.api.aws/123456789012/test-queue', + 'https://sqs-fips.us-gov-west-1.amazonaws.com/123456789012/test-queue', + 'https://queue.amazonaws.com/123456789012/test-queue', + 'https://sqs.cn-north-1.api.amazonwebservices.com.cn/123456789012/test-queue', + 'https://sqs.eusc-de-east-1.amazonaws.eu/123456789012/test-queue', + ])('accepts the AWS queue URL %s', async (queueUrl) => { + mockSend.mockResolvedValue({ MessageId: 'id' }) + + await expect( + executeSqsSend({ ...CONNECTION, queueUrl, data: { ok: true } }) + ).resolves.toMatchObject({ id: 'id' }) + }) }) diff --git a/apps/sim/lib/internal/sqs/operations.ts b/apps/sim/lib/internal/sqs/operations.ts index f445711eb8a..d98f3e3db5c 100644 --- a/apps/sim/lib/internal/sqs/operations.ts +++ b/apps/sim/lib/internal/sqs/operations.ts @@ -44,13 +44,76 @@ import type { AwsSqsSetQueueAttributesBody } from '@/lib/api/contracts/tools/aws import type { AwsSqsStartMessageMoveTaskBody } from '@/lib/api/contracts/tools/aws/sqs-start-message-move-task' import type { AwsSqsTagQueueBody } from '@/lib/api/contracts/tools/aws/sqs-tag-queue' import type { AwsSqsUntagQueueBody } from '@/lib/api/contracts/tools/aws/sqs-untag-queue' +import { isHosted } from '@/lib/core/config/env-flags' import { createSqsClient } from '@/lib/internal/sqs/client' import type { SqsConnectionConfig } from '@/tools/sqs/types' +/** DNS suffixes of every AWS partition, from the SDK partition table. */ +const AWS_DNS_SUFFIXES = [ + 'amazonaws.com', + 'amazonaws.com.cn', + 'amazonaws.eu', + 'c2s.ic.gov', + 'sc2s.sgov.gov', + 'csp.hci.ic.gov', + 'cloud.adc-e.uk', +] + +/** Dual-stack DNS suffixes of every AWS partition, from the SDK partition table. */ +const AWS_DUAL_STACK_DNS_SUFFIXES = [ + 'api.aws', + 'api.amazonwebservices.com.cn', + 'api.amazonwebservices.eu', + 'api.aws.ic.gov', + 'api.aws.scloud', + 'api.aws.hci.ic.gov', + 'api.cloud-aws.adc-e.uk', +] + +const escapeSuffix = (suffix: string) => suffix.replaceAll('.', '\\.') +const anyOf = (suffixes: string[]) => `(${suffixes.map(escapeSuffix).join('|')})` + +/** + * Hostnames of SQS endpoints: regional and FIPS, dual-stack, VPC interface, and the + * legacy `queue.amazonaws.com` forms. Anchored so other AWS hosts (EC2, API + * Gateway, ELB) never match. + */ +const AWS_SQS_HOSTS = [ + new RegExp(`^sqs(-fips)?\\.[a-z0-9-]+\\.${anyOf(AWS_DNS_SUFFIXES)}$`), + new RegExp(`^sqs(-fips)?\\.[a-z0-9-]+\\.${anyOf(AWS_DUAL_STACK_DNS_SUFFIXES)}$`), + new RegExp(`^vpce-[a-z0-9-]+\\.sqs\\.[a-z0-9-]+\\.vpce\\.${anyOf(AWS_DNS_SUFFIXES)}$`), + /^([a-z0-9-]+\.)?queue\.amazonaws\.com(\.cn)?$/, +] + +/** + * The SQS client sends each request to the host of its `QueueUrl` (the SDK's + * `useQueueUrlAsEndpoint` default), so a queue URL on any other host would carry + * the signed request, including a saved credential's session token, off AWS. + * Enforced on the hosted deployment only: self-hosted installs may point queue + * URLs at SQS-compatible endpoints (LocalStack, ElasticMQ). A value that does not + * parse as a URL is left for SQS to reject, since the SDK only redirects when it + * parses. + */ +function assertAwsQueueUrl(queueUrl: string | undefined) { + if (!queueUrl || !isHosted) return + let parsed: URL + try { + parsed = new URL(queueUrl) + } catch { + return + } + const host = parsed.hostname.toLowerCase() + if (parsed.protocol === 'https:' && AWS_SQS_HOSTS.some((pattern) => pattern.test(host))) return + throw new Error( + 'Queue URL must be an Amazon SQS queue URL (e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue)' + ) +} + async function withSqsClient( - config: SqsConnectionConfig, + config: SqsConnectionConfig & { queueUrl?: string }, execute: (client: SQSClient) => Promise ): Promise { + assertAwsQueueUrl(config.queueUrl) const client = createSqsClient(config) try { return await execute(client) diff --git a/apps/sim/lib/internal/square/operations.test.ts b/apps/sim/lib/internal/square/operations.test.ts index 2bfeaeca13d..49508c67358 100644 --- a/apps/sim/lib/internal/square/operations.test.ts +++ b/apps/sim/lib/internal/square/operations.test.ts @@ -64,5 +64,26 @@ describe('executeSquareCreateCatalogImage', () => { idempotency_key: 'stable-key', object_id: 'item-1', }) + expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://connect.squareup.com/v2/catalog/images') + }) + + it('uploads with a sandbox credential to the Square sandbox', async () => { + const response = await executeSquareCreateCatalogImage( + { + accessToken: 'sandbox-token', + environment: 'sandbox', + file: FILE, + fileName: null, + objectId: null, + caption: null, + idempotencyKey: 'stable-key', + }, + { userId: 'user-1', requestId: 'request-1' } + ) + + expect(response.status).toBe(200) + expect(vi.mocked(fetch).mock.calls[0][0]).toBe( + 'https://connect.squareupsandbox.com/v2/catalog/images' + ) }) }) diff --git a/apps/sim/lib/internal/square/operations.ts b/apps/sim/lib/internal/square/operations.ts index 13439d0830d..21e85f4fd4c 100644 --- a/apps/sim/lib/internal/square/operations.ts +++ b/apps/sim/lib/internal/square/operations.ts @@ -10,7 +10,7 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles } from '@/lib/uploads/utils/file' import { downloadFileFromStorage } from '@/lib/uploads/utils/file.server' import { assertToolFileAccess } from '@/app/api/files/authorization' -import { SQUARE_API_VERSION, SQUARE_BASE_URL } from '@/tools/square/types' +import { SQUARE_API_VERSION, SQUARE_ENVIRONMENTS } from '@/tools/square/types' const logger = createLogger('SquareCatalogImage') const MAX_SQUARE_RESPONSE_BYTES = 10 * 1024 * 1024 @@ -71,15 +71,18 @@ export async function executeSquareCreateCatalogImage( input.fileName || userFile.name ) - const response = await fetch(`${SQUARE_BASE_URL}/v2/catalog/images`, { - method: 'POST', - headers: { - Authorization: `Bearer ${input.accessToken}`, - 'Square-Version': SQUARE_API_VERSION, - }, - body: formData, - signal: context.signal, - }) + const response = await fetch( + `${SQUARE_ENVIRONMENTS.apiBase(input.environment)}/v2/catalog/images`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${input.accessToken}`, + 'Square-Version': SQUARE_API_VERSION, + }, + body: formData, + signal: context.signal, + } + ) if (!response.ok) { const errorText = await readResponseTextWithLimit(response, { diff --git a/apps/sim/lib/internal/square/schema.ts b/apps/sim/lib/internal/square/schema.ts index 7601289c158..3e5b6e09e56 100644 --- a/apps/sim/lib/internal/square/schema.ts +++ b/apps/sim/lib/internal/square/schema.ts @@ -1,8 +1,13 @@ import { z } from 'zod' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' +import { SQUARE_ENVIRONMENTS } from '@/tools/square/types' export const squareCatalogImageInputSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), + environment: z + .string() + .refine((value) => SQUARE_ENVIRONMENTS.has(value), 'Unknown Square environment') + .optional(), file: FileInputSchema, fileName: z.string().optional().nullable(), objectId: z.string().optional().nullable(), diff --git a/apps/sim/lib/internal/sts/client.ts b/apps/sim/lib/internal/sts/client.ts index 6ec39d21825..aaf30eb5a6d 100644 --- a/apps/sim/lib/internal/sts/client.ts +++ b/apps/sim/lib/internal/sts/client.ts @@ -78,6 +78,7 @@ export async function assumeRole( policyArns?: string | null, tags?: string | null, transitiveTagKeys?: string | null, + sourceIdentity?: string | null, signal?: AbortSignal ) { const command = new AssumeRoleCommand({ @@ -88,6 +89,7 @@ export async function assumeRole( ...(externalId ? { ExternalId: externalId } : {}), ...(serialNumber ? { SerialNumber: serialNumber } : {}), ...(tokenCode ? { TokenCode: tokenCode } : {}), + ...(sourceIdentity ? { SourceIdentity: sourceIdentity } : {}), ...(() => { const arns = parsePolicyArns(policyArns) return arns ? { PolicyArns: arns } : {} diff --git a/apps/sim/lib/internal/sts/operations.ts b/apps/sim/lib/internal/sts/operations.ts index 846b842b6c0..21dd0f514da 100644 --- a/apps/sim/lib/internal/sts/operations.ts +++ b/apps/sim/lib/internal/sts/operations.ts @@ -32,6 +32,7 @@ export async function executeStsAssumeRole(input: StsAssumeRoleInput, signal?: A input.policyArns, input.tags, input.transitiveTagKeys, + input.sourceIdentity, signal ) } finally { diff --git a/apps/sim/lib/internal/sts/schema.ts b/apps/sim/lib/internal/sts/schema.ts index 3cfa219e313..c028fa34658 100644 --- a/apps/sim/lib/internal/sts/schema.ts +++ b/apps/sim/lib/internal/sts/schema.ts @@ -53,6 +53,12 @@ export const stsAssumeRoleInputSchema = authenticatedInputSchema.extend({ (value) => !value || value.split(',').filter((key) => key.trim().length > 0).length <= 50, { message: 'A maximum of 50 transitive tag keys can be provided' } ), + sourceIdentity: z + .string() + .min(2) + .max(64) + .regex(/^[\w+=,.@-]*$/, 'Source identity may contain only letters, digits, and _+=,.@-') + .nullish(), }) export const stsAssumeRoleWithWebIdentityInputSchema = z.object({ diff --git a/apps/sim/lib/internal/textract/document-input.ts b/apps/sim/lib/internal/textract/document-input.ts index 0c4cc6d32ec..1cc30d4e1f8 100644 --- a/apps/sim/lib/internal/textract/document-input.ts +++ b/apps/sim/lib/internal/textract/document-input.ts @@ -7,12 +7,12 @@ import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { TextractOperationError } from '@/lib/internal/textract/errors' import { isModelSafeWorkspaceFileKey, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, } from '@/lib/uploads/contexts/workspace/file-secret-provenance' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import type { RawFileInput } from '@/lib/uploads/utils/file' import { extractStorageKey, @@ -27,6 +27,16 @@ import { assertToolFileAccess } from '@/app/api/files/authorization' type TextractLogger = ReturnType +/** Textract's synchronous operations accept at most 10 MB of document bytes. */ +const TEXTRACT_SYNC_MAX_DOCUMENT_BYTES = 10 * 1024 * 1024 + +function documentTooLargeError(): TextractOperationError { + return new TextractOperationError( + 'Document exceeds the 10 MB limit for synchronous Textract processing. For a larger PDF or TIFF, upload it to S3 and use Multi-Page mode where the operation supports it.', + 413 + ) +} + export interface ResolvedDocument { bytes: Buffer contentType: string @@ -54,22 +64,28 @@ async function fetchDocumentBytes( throw new TextractOperationError(urlValidation.error || 'Invalid document URL', 400) } - const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { - profile, - method: 'GET', - signal, - }) - if (!response.ok) { - await response.text().catch(() => {}) - throw new TextractOperationError( - `Failed to fetch document: ${response.statusText}`, - response.status - ) - } + try { + const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { + profile, + method: 'GET', + signal, + maxResponseBytes: TEXTRACT_SYNC_MAX_DOCUMENT_BYTES, + }) + if (!response.ok) { + await response.text().catch(() => {}) + throw new TextractOperationError( + `Failed to fetch document: ${response.statusText}`, + response.status + ) + } - const arrayBuffer = await response.arrayBuffer() - const contentType = response.headers.get('content-type') || 'application/octet-stream' - return { bytes: Buffer.from(arrayBuffer), contentType } + const arrayBuffer = await response.arrayBuffer() + const contentType = response.headers.get('content-type') || 'application/octet-stream' + return { bytes: Buffer.from(arrayBuffer), contentType } + } catch (error) { + if (isPayloadSizeLimitError(error)) throw documentTooLargeError() + throw error + } } export async function resolveDocumentInput( @@ -107,12 +123,16 @@ export async function resolveDocumentInput( } signal?.throwIfAborted() - const { buffer, contentType } = await downloadServableFileFromStorage( - userFile, - requestId, - logger, - { maxBytes: MAX_BUFFERED_TRANSFER_BYTES } - ) + let downloaded: Awaited> + try { + downloaded = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: TEXTRACT_SYNC_MAX_DOCUMENT_BYTES, + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) throw documentTooLargeError() + throw error + } + const { buffer, contentType } = downloaded const resolvedContentType = contentType || userFile.type || 'application/octet-stream' return { diff --git a/apps/sim/lib/internal/textract/operations.ts b/apps/sim/lib/internal/textract/operations.ts index 2551ab46a29..62799a1468b 100644 --- a/apps/sim/lib/internal/textract/operations.ts +++ b/apps/sim/lib/internal/textract/operations.ts @@ -12,6 +12,7 @@ import { StartDocumentTextDetectionCommand, StartExpenseAnalysisCommand, TextractClient, + type Warning, } from '@aws-sdk/client-textract' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' @@ -46,6 +47,7 @@ interface TextractDocumentResult { JobStatus?: string StatusMessage?: string NextToken?: string + Warnings?: Warning[] Blocks?: unknown[] DocumentMetadata?: { Pages?: number } AnalyzeDocumentModelVersion?: string @@ -56,6 +58,7 @@ interface TextractExpenseResult { JobStatus?: string StatusMessage?: string NextToken?: string + Warnings?: Warning[] ExpenseDocuments?: ExpenseDocument[] DocumentMetadata?: { Pages?: number } AnalyzeExpenseModelVersion?: string @@ -78,6 +81,26 @@ function validateModelInputProvenance(input: unknown, headers: Headers): NextRes ) } +function mergeWarnings(accumulated: Warning[] = [], page: Warning[] = []): Warning[] { + const seen = new Set(accumulated.map((warning) => JSON.stringify(warning))) + return [...accumulated, ...page.filter((warning) => !seen.has(JSON.stringify(warning)))] +} + +function asyncJobOutput(result: { + JobStatus?: string + StatusMessage?: string + Warnings?: Warning[] +}) { + return { + jobStatus: result.JobStatus ?? null, + statusMessage: result.StatusMessage ?? null, + warnings: (result.Warnings ?? []).map((warning) => ({ + errorCode: warning.ErrorCode ?? null, + pages: warning.Pages ?? [], + })), + } +} + function createTextractClient(input: { region: string accessKeyId: string @@ -170,6 +193,7 @@ export async function executeTextractParse( ...accumulated, ...page, Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], + Warnings: mergeWarnings(accumulated.Warnings, page.Warnings), }), signal ) @@ -186,6 +210,7 @@ export async function executeTextractParse( documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, modelVersion: result.AnalyzeDocumentModelVersion ?? result.DetectDocumentTextModelVersion, + ...asyncJobOutput(result), }, }) } finally { @@ -307,6 +332,7 @@ export async function executeTextractAnalyzeExpense( ...(accumulated.ExpenseDocuments ?? []), ...(page.ExpenseDocuments ?? []), ], + Warnings: mergeWarnings(accumulated.Warnings, page.Warnings), }), signal ) @@ -317,6 +343,7 @@ export async function executeTextractAnalyzeExpense( expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []), documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, modelVersion: result.AnalyzeExpenseModelVersion, + ...asyncJobOutput(result), }, }) } finally { diff --git a/apps/sim/lib/internal/tool-operations/registry.server.test.ts b/apps/sim/lib/internal/tool-operations/registry.server.test.ts index bc2a5ded6ab..614ed8f3ad5 100644 --- a/apps/sim/lib/internal/tool-operations/registry.server.test.ts +++ b/apps/sim/lib/internal/tool-operations/registry.server.test.ts @@ -1,15 +1,20 @@ import { describe, expect, it, vi } from 'vitest' +import { DATABASE_CONNECTION_DESCRIPTORS } from '@/lib/credentials/database-connections/descriptors' import { AWS_CREDENTIAL_OPERATION_TOOL_IDS, getInternalToolOperationHandler, getRegisteredInternalToolOperationIds, isInternalToolOperationRegistered, + SAVED_DATABASE_CONNECTION_TOOL_SERVICES, } from '@/lib/internal/tool-operations/registry.server' import { getToolMetadata } from '@/tools/metadata' -import { AWS_SESSION_SIGNED_TOOL_IDS } from '@/tools/shared/saved-credentials' +import { + AWS_SESSION_SIGNED_TOOL_IDS, + OPERATION_RESOLVED_CREDENTIAL_PROVIDERS, +} from '@/tools/shared/saved-credentials' import { getToolIds } from '@/tools/tool-ids' -// The AWS routing check reads real tool policies. +// The saved-credential routing checks read real tool policies. vi.unmock('@/tools/metadata') /** @@ -63,4 +68,29 @@ describe('internal tool operation registry', () => { expect(isInternalToolOperationRegistered(toolId), toolId).toBe(false) } }) + + /** + * The saved-credential contract check skips operation-resolved providers, so + * a tool that names a saved database connection but runs outside a wrapped + * family would pass it and still run on the call's inline fields. + */ + it('routes every saved-database-connection tool through a family that loads its connection', () => { + const databaseServices = new Set( + Object.values(DATABASE_CONNECTION_DESCRIPTORS).map(({ service }) => service) + ) + const databaseTools = getToolIds().filter((toolId) => { + const provider = getToolMetadata(toolId)?.oauth?.provider + return provider !== undefined && databaseServices.has(provider) + }) + + for (const service of databaseServices) { + expect(OPERATION_RESOLVED_CREDENTIAL_PROVIDERS.has(service), service).toBe(true) + } + expect(databaseTools.length).toBeGreaterThan(0) + for (const toolId of databaseTools) { + expect(SAVED_DATABASE_CONNECTION_TOOL_SERVICES.get(toolId), toolId).toBe( + getToolMetadata(toolId)?.oauth?.provider + ) + } + }) }) diff --git a/apps/sim/lib/internal/tool-operations/registry.server.ts b/apps/sim/lib/internal/tool-operations/registry.server.ts index 76f84db5dd7..69ffdfd554a 100644 --- a/apps/sim/lib/internal/tool-operations/registry.server.ts +++ b/apps/sim/lib/internal/tool-operations/registry.server.ts @@ -1,7 +1,16 @@ +import { + DATABASE_CONNECTION_DESCRIPTORS, + type DatabaseConnectionProviderId, +} from '@/lib/credentials/database-connections/descriptors' import type { InternalToolOperationHandler, InternalToolOperationResult, } from '@/lib/internal/tool-operations/types' +import { + MSSQL_SERVICE_ACCOUNT_PROVIDER_ID, + MYSQL_SERVICE_ACCOUNT_PROVIDER_ID, + POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/oauth/types' import { isMcpTool } from '@/executor/constants' type InternalToolOperationHandlerLoader = () => Promise< @@ -1520,6 +1529,34 @@ function registerAwsFamily( for (const toolId of toolIds) AWS_CREDENTIAL_OPERATION_TOOL_IDS.add(toolId) } +/** + * Tool id → saved-connection service for every SQL family registered through + * `registerSavedDatabaseFamily`, so the wiring can be checked. + */ +export const SAVED_DATABASE_CONNECTION_TOOL_SERVICES = new Map() + +/** + * Registers a SQL family wrapped so it also runs on a saved database + * connection, and records its tool ids. The wrapper is a no-op for calls + * without an executor-authorized connection, so inline-connection calls reach + * the family handler unchanged. + */ +function registerSavedDatabaseFamily( + providerId: DatabaseConnectionProviderId, + toolIds: readonly string[], + load: () => Promise +): void { + registerFamily(handlerLoaders, toolIds, async () => { + const [{ withSavedDatabaseConnection }, handler] = await Promise.all([ + import('@/lib/credentials/database-connections/server'), + load(), + ]) + return withSavedDatabaseConnection(providerId, handler) + }) + const { service } = DATABASE_CONNECTION_DESCRIPTORS[providerId] + for (const toolId of toolIds) SAVED_DATABASE_CONNECTION_TOOL_SERVICES.set(toolId, service) +} + registerFamily(handlerLoaders, ASHBY_TOOL_IDS, async () => { return (await import('@/lib/internal/ashby/execute-tool')).executeAshbyTool }) @@ -1582,9 +1619,11 @@ registerAwsFamily( CLOUDTRAIL_TOOL_IDS, async () => (await import('@/lib/internal/cloudtrail/execute-tool')).executeCloudtrailTool ) -registerFamily(handlerLoaders, POSTGRESQL_TOOL_IDS, async () => { - return (await import('@/lib/internal/postgresql/execute-tool')).executePostgresqlTool -}) +registerSavedDatabaseFamily( + POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID, + POSTGRESQL_TOOL_IDS, + async () => (await import('@/lib/internal/postgresql/execute-tool')).executePostgresqlTool +) registerFamily(handlerLoaders, REDIS_TOOL_IDS, async () => { return (await import('@/lib/internal/redis/execute-tool')).executeRedisTool }) @@ -1600,9 +1639,11 @@ registerAwsFamily( CODEPIPELINE_TOOL_IDS, async () => (await import('@/lib/internal/codepipeline/execute-tool')).executeCodepipelineTool ) -registerFamily(handlerLoaders, MYSQL_TOOL_IDS, async () => { - return (await import('@/lib/internal/mysql/execute-tool')).executeMysqlTool -}) +registerSavedDatabaseFamily( + MYSQL_SERVICE_ACCOUNT_PROVIDER_ID, + MYSQL_TOOL_IDS, + async () => (await import('@/lib/internal/mysql/execute-tool')).executeMysqlTool +) registerAwsFamily( ATHENA_TOOL_IDS, async () => (await import('@/lib/internal/athena/execute-tool')).executeAthenaTool @@ -1623,9 +1664,11 @@ registerAwsFamily( registerFamily(handlerLoaders, JUPYTER_TOOL_IDS, async () => { return (await import('@/lib/internal/jupyter/execute-tool')).executeJupyterTool }) -registerFamily(handlerLoaders, MSSQL_TOOL_IDS, async () => { - return (await import('@/lib/internal/mssql/execute-tool')).executeMssqlTool -}) +registerSavedDatabaseFamily( + MSSQL_SERVICE_ACCOUNT_PROVIDER_ID, + MSSQL_TOOL_IDS, + async () => (await import('@/lib/internal/mssql/execute-tool')).executeMssqlTool +) registerFamily(handlerLoaders, ORACLE_FUSION_FINANCIALS_TOOL_IDS, async () => { return (await import('@/lib/internal/oracle-fusion-financials/execute-tool')) .executeOracleFusionFinancialsTool diff --git a/apps/sim/lib/internal/tool-operations/types.ts b/apps/sim/lib/internal/tool-operations/types.ts index 4e85821dfae..bad836cdbd5 100644 --- a/apps/sim/lib/internal/tool-operations/types.ts +++ b/apps/sim/lib/internal/tool-operations/types.ts @@ -29,7 +29,8 @@ export interface InternalToolOperationContext { /** * The saved service-account credential the executor authorized for this call, * set only from its own credential resolution, never from tool input. - * Operations that resolve a credential server-side (AWS session keys) read it. + * Operations that resolve a credential server-side (AWS session keys, saved SQL + * connections) read it. */ authorizedServiceAccountCredential?: AuthorizedServiceAccountCredential /** Trusted source block for saved MCP operation restrictions. */ diff --git a/apps/sim/lib/internal/zoominfo/client.ts b/apps/sim/lib/internal/zoominfo/client.ts index 223100b23c0..5fc03de3859 100644 --- a/apps/sim/lib/internal/zoominfo/client.ts +++ b/apps/sim/lib/internal/zoominfo/client.ts @@ -5,6 +5,7 @@ import { MAX_JSON_API_RESPONSE_BYTES, secureFetchWithValidation, } from '@/lib/core/security/input-validation.server' +import { throttledRetryAfterMs } from '@/lib/credentials/token-service-accounts/errors' import { assertSafeZoomInfoUrl, ZOOMINFO_API_BASE, @@ -51,7 +52,11 @@ export class ZoomInfoOperationError extends Error { * provider status), and carries that status for the saved-credential minter. */ export class ZoomInfoTokenError extends ZoomInfoOperationError { - constructor(readonly tokenStatus: number) { + constructor( + readonly tokenStatus: number, + /** The token endpoint's `Retry-After` on a 429 or 503, in milliseconds. */ + readonly retryAfterMs?: number + ) { super(`ZoomInfo token request failed: HTTP ${tokenStatus}`, 500) this.name = 'ZoomInfoTokenError' } @@ -129,7 +134,7 @@ export async function requestZoomInfoToken( if (!response.ok) { const text = await response.text().catch(() => '') logger.warn('ZoomInfo token fetch failed', { requestId, status: response.status, error: text }) - throw new ZoomInfoTokenError(response.status) + throw new ZoomInfoTokenError(response.status, throttledRetryAfterMs(response)) } const data: unknown = await response.json() if (!isRecordLike(data) || typeof data.access_token !== 'string' || !data.access_token.trim()) { diff --git a/apps/sim/lib/knowledge/__integration__/search-source-setup.integration.ts b/apps/sim/lib/knowledge/__integration__/search-source-setup.integration.ts index 85f98cabd07..9ad0ac3616d 100644 --- a/apps/sim/lib/knowledge/__integration__/search-source-setup.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-source-setup.integration.ts @@ -492,14 +492,17 @@ describe('Search source identity and concurrent creation', () => { .where(eq(resourcePolicy.resourceId, searchGroupId)) }) await locked - const append = ensureWorkspaceAccountsGroup(ids.workspaceId, ids.aliceId, { - provider: 'jira', - label: 'Jira', - required: false, - }) + /** Handled from the start: the refusal can land before the grant's commit resolves here. */ + const refused = expect( + ensureWorkspaceAccountsGroup(ids.workspaceId, ids.aliceId, { + provider: 'jira', + label: 'Jira', + required: false, + }) + ).rejects.toThrow('has workflow access') releaseGrant() await grant - await expect(append).rejects.toThrow('has workflow access') + await refused const existing = await ensureWorkspaceAccountsGroup(ids.workspaceId, ids.aliceId, { provider: 'google-drive', label: 'Google Drive', diff --git a/apps/sim/lib/knowledge/application/connector-access.test.ts b/apps/sim/lib/knowledge/application/connector-access.test.ts index 11f031f2a64..1ea4ac82c75 100644 --- a/apps/sim/lib/knowledge/application/connector-access.test.ts +++ b/apps/sim/lib/knowledge/application/connector-access.test.ts @@ -13,6 +13,7 @@ import { credentialGroupsSelfEnrollmentMockFns, } from '@sim/testing/mocks/credential-groups-self-enrollment.mock' import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' import { githubInstallationMock, githubInstallationMockFns, @@ -61,6 +62,7 @@ vi.mock('@/lib/knowledge/application/connector-credential', () => ({ requireConnectorCredential: hoisted.credential, })) vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) vi.mock('@/lib/oauth/github-installation', () => githubInstallationMock) vi.mock('@/lib/knowledge/application/contexts', () => knowledgeContextsMock) vi.mock('@/connectors/registry', () => ({ getConnectorMeta: hoisted.meta })) @@ -132,6 +134,10 @@ const row = { encryptedApiKey: 'encrypted-fixture', sourceConfig: { host: 'gitlab.example.test', project: 'one' }, } +const savedAwsAuth = { + mode: 'apiKey', + savedCredential: { provider: 'aws', label: 'Saved AWS credential' }, +} as const beforeEach(() => { knowledgeContextsMockFns.mockResolveActiveKnowledgeConnectorContext.mockResolvedValue({ @@ -145,6 +151,7 @@ beforeEach(() => { }) workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('admin') mocks.connector.mockResolvedValue(row) + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) mocks.meta.mockReturnValue({ name: 'GitLab', auth: { mode: 'apiKey' }, mirrorsSourceAcls: true }) mocks.validate.mockResolvedValue(null) mocks.token.mockResolvedValue({ accessToken: 'provider-fixture' }) @@ -357,6 +364,37 @@ describe('connector access application boundary', () => { expect(mocks.validate).not.toHaveBeenCalled() }) + it('refuses moving a key connector that stores its key onto a saved credential', async () => { + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(true) + mocks.meta.mockReturnValue({ name: 'Amazon S3', auth: savedAwsAuth }) + await expect( + updateKnowledgeConnectorAccess.execute({ + principal, + input: { ...input, accessMode: 'workspace', credentialId: 'aws-credential' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('lets a connector already on a saved credential move to another one', async () => { + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(true) + mocks.meta.mockReturnValue({ name: 'Amazon S3', auth: savedAwsAuth }) + mocks.connector.mockResolvedValue({ + ...row, + credentialId: 'aws-credential', + encryptedApiKey: null, + }) + await updateKnowledgeConnectorAccess.execute({ + principal, + input: { ...input, accessMode: 'workspace', credentialId: 'other-aws-credential' }, + }) + expect(mocks.update).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ credentialId: 'other-aws-credential' }), + }) + ) + }) + it('rejects separate content credentials for providers without the capability', async () => { mocks.meta.mockReturnValue({ name: 'Confluence', diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index 7bcd21e0718..f0f37152b7c 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -35,12 +35,14 @@ import { mirrorsSourceAcls, supportsConnectorAccessMode, } from '@/lib/knowledge/connectors/access-modes' +import { requireConnectorCredentialService } from '@/lib/knowledge/connectors/credential-service' import { validateKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-access' import { provisionKnowledgeConnectorMembersBinding, sourceIdentityBinding, } from '@/lib/knowledge/connectors/member-provisioning' import { assertConnectorMirrorsSourceAcls } from '@/lib/knowledge/connectors/mirrored-access' +import { requireConnectorSavedCredentialSetup } from '@/lib/knowledge/connectors/saved-credential-rollout' import { type ConnectorAccessTarget, performUpdateKnowledgeConnectorAccess, @@ -49,7 +51,7 @@ import { import { getKnowledgeConnector } from '@/lib/knowledge/orchestration/connectors' import type { KnowledgeOperationSource } from '@/lib/knowledge/orchestration/shared' import { requireOrganizationSearchApproval } from '@/lib/knowledge/search/integration-policy' -import { getServiceConfigByProviderId, getServiceConfigByServiceId } from '@/lib/oauth' +import { getConnectorCredentialService } from '@/connectors/auth' import { getConnectorMeta } from '@/connectors/registry' import type { ConnectorMeta } from '@/connectors/types' @@ -282,6 +284,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ await requireUsableCredential({ principal, credentialId, + storesApiKey: Boolean(connector.encryptedApiKey), connectorMeta, sourceConfig, ...owner, @@ -312,6 +315,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ credentialId: await requireUsableCredential({ principal, credentialId: input.credentialId, + storesApiKey: Boolean(connector.encryptedApiKey), connectorMeta, sourceConfig, ...owner, @@ -391,10 +395,16 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ * provider, and one that yields a token, since the connector syncs as it from * then on. API-key connectors keep their canonical encrypted key; the shared * source validation verifies it against the target mode before any mutation. + * + * A key connector that stores its key never moves onto its saved-credential + * alternative: the key would stay on the row and silently take over again once + * the credential is deleted, so switching means creating the connector anew. */ async function requireUsableCredential(input: { principal: Principal credentialId: string | null | undefined + /** Whether the connector row stores an encrypted API key. */ + storesApiKey: boolean connectorMeta: Pick sourceConfig: Record workspaceId?: string @@ -404,26 +414,28 @@ async function requireUsableCredential(input: { accessMode: ConnectorAccessMode }): Promise { const { auth } = input.connectorMeta - if (auth.mode !== 'oauth') { + const credentialService = getConnectorCredentialService(auth) + if (!credentialService) { if (input.credentialId) { throw new OrchestrationError('validation', 'API-key connectors use their configured API key') } return null } if (!input.credentialId) { + if (auth.mode === 'apiKey') return null throw new OrchestrationError( 'validation', 'credentialId is required for a mode that syncs with one credential' ) } - const service = - getServiceConfigByServiceId(auth.provider) ?? getServiceConfigByProviderId(auth.provider) - if (!service) { + if (auth.mode === 'apiKey' && input.storesApiKey) { throw new OrchestrationError( 'validation', - `${input.connectorMeta.name} has no OAuth service to validate the credential against` + `This ${input.connectorMeta.name} connector signs with its stored key. Create a new connector to use a saved credential.` ) } + await requireConnectorSavedCredentialSetup(auth, input.credentialId) + const service = requireConnectorCredentialService(input.connectorMeta) const token = await resolveConnectorCredentialAccessToken({ principal: input.principal, credentialId: input.credentialId, diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index 2156453d5a2..c7dbf6c5df3 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -8,6 +8,11 @@ import { credentialsAccessMockFns, } from '@sim/testing/mocks/credentials-access.mock' import { environmentUtilsMockFns } from '@sim/testing/mocks/environment-utils.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { + internalAwsCredentialsMock, + internalAwsCredentialsMockFns, +} from '@sim/testing/mocks/internal-aws-credentials.mock' import { knowledgeAccessScopeMock, knowledgeAccessScopeMockFns, @@ -71,6 +76,8 @@ vi.mock('@/lib/credentials/application/organization-credentials', () => ({ })) vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) +vi.mock('@/lib/internal/aws/credentials', () => internalAwsCredentialsMock) +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) vi.mock('@/lib/api-key/crypto', () => ({ decryptApiKey: hoisted.decryptApiKey })) vi.mock('@/lib/permission-groups/resolve.server', () => permissionGroupsResolveMock) @@ -89,6 +96,15 @@ vi.mock('@/connectors/registry.server', () => ({ auth: { mode: 'oauth', requiredScopes: ['read:confluence-content.all'] }, validateConfig: hoisted.validateConnectorConfig, }, + s3: { + name: 'Amazon S3', + auth: { + mode: 'apiKey', + label: 'Secret Access Key', + savedCredential: { provider: 'aws', label: 'Saved AWS credential' }, + }, + validateConfig: hoisted.validateConnectorConfig, + }, google_drive: { name: 'Google Drive', auth: { @@ -130,6 +146,7 @@ import { confluenceConnectorMeta } from '@/connectors/confluence/meta' import { gmailConnectorMeta } from '@/connectors/gmail/meta' import { googleCalendarConnectorMeta } from '@/connectors/google-calendar/meta' import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta' +import { s3ConnectorMeta } from '@/connectors/s3/meta' const mocks = { ...hoisted, @@ -341,6 +358,43 @@ describe('knowledge connector application use cases', () => { expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() }) + it('refuses another service’s credential for an S3 connector before resolving it', async () => { + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValueOnce(true) + mocks.getCredentialActorContext.mockResolvedValueOnce({ + credential: { + id: 'credential-1', + workspaceId: 'workspace-b', + providerId: 'google-service-account', + }, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + }) + mocks.createConnector.mockImplementationOnce( + async (input: { resolveAccessToken: (credentialId: string) => Promise }) => { + await input.resolveAccessToken('credential-1') + throw new Error('An ineligible credential reached connector persistence') + } + ) + await expect( + createKnowledgeConnector.execute({ + principal: patPrincipal, + input: { + knowledgeBaseId: 'knowledge-b', + connectorType: 's3', + credentialId: 'credential-1', + sourceConfig: { region: 'us-east-1', bucket: 'docs' }, + syncIntervalMinutes: 1440, + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining('belongs to another service'), + }) + expect(internalAwsCredentialsMockFns.mockResolveAuthorizedAwsCredential).not.toHaveBeenCalled() + }) + it('rejects a personal Confluence OAuth credential before central token use', async () => { await expect( resolveConnectorCredentialAccessToken({ @@ -1022,6 +1076,75 @@ describe('organization connector credential authorization', () => { } ) + it.each([ + ['refused by AWS', { name: 'AccessDenied' }, 400], + ['throttled by AWS', { name: 'ThrottlingException', $metadata: { httpStatusCode: 400 } }, 500], + ])( + 'projects a saved AWS credential %s with the matching setup status', + async (_case, failure, status) => { + mocks.authorizeOrganizationCredentialUse.mockResolvedValueOnce({ + credential: { ...credential, providerId: 'aws' }, + userId: principal.userId, + }) + internalAwsCredentialsMockFns.mockResolveAuthorizedAwsCredential.mockRejectedValueOnce( + Object.assign(new Error('AWS said no'), failure) + ) + const error = await resolveConnectorCredentialAccessToken({ + ...input, + auth: s3ConnectorMeta.auth, + accessMode: 'workspace', + sourceConfig: { region: 'us-east-1', bucket: 'docs' }, + }).catch((error: unknown) => error) + expect(internalOrchestrationErrorPolicy.project(error)?.status ?? 500).toBe(status) + } + ) + + it.each(['result', 'exception'] as const)( + 'redacts a saved AWS session from settings validation %s', + async (failure) => { + const awsSession = { + accessKeyId: 'ASIAEXAMPLE', + secretAccessKey: 'session/secret', + sessionToken: 'session-token', + } + mocks.authorizeOrganizationCredentialUse.mockResolvedValueOnce({ + credential: { ...credential, providerId: 'aws' }, + userId: principal.userId, + }) + internalAwsCredentialsMockFns.mockResolveAuthorizedAwsCredential.mockResolvedValueOnce( + awsSession + ) + const message = `Denied ${awsSession.secretAccessKey} ${awsSession.sessionToken}` + if (failure === 'exception') { + mocks.validateConnectorConfig.mockRejectedValueOnce( + new OrchestrationError('validation', message) + ) + } else { + mocks.validateConnectorConfig.mockResolvedValueOnce({ valid: false, error: message }) + } + const result = validateConnectorSourceConfig({ + principal, + requestId: 'request', + organizationId: 'org', + actingUserId: principal.userId, + sourceConfig: { region: 'us-east-1', bucket: 'docs' }, + connector: { + id: 'connector-s3', + connectorType: 's3', + credentialId: credential.id, + encryptedApiKey: null, + accessMode: 'workspace', + } as Parameters[0]['connector'], + }) + const redacted = 'Denied [REDACTED] [REDACTED]' + if (failure === 'exception') { + await expect(result).rejects.toMatchObject({ code: 'validation', message: redacted }) + } else { + await expect(result).resolves.toEqual({ errorCode: 'validation', message: redacted }) + } + } + ) + it('does not mint a token after the credential creator leaves the organization', async () => { mocks.resolveTokenIdentity.mockResolvedValueOnce(null) await expect(resolveConnectorCredentialAccessToken(input)).resolves.toBeNull() diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 33a2b3ab683..735932b5178 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -58,9 +58,12 @@ import { } from '@/lib/knowledge/connectors/access-modes' import { type ConnectorAccessToken, + ConnectorAwsCredentialError, + connectorTokenSecrets, resolveConnectorAccessToken, syncContextForToken, } from '@/lib/knowledge/connectors/access-token' +import { requireConnectorCredentialService } from '@/lib/knowledge/connectors/credential-service' import { requiresConnectorIndexing } from '@/lib/knowledge/connectors/indexing-policy' import { provisionKnowledgeConnectorMembersBinding, @@ -78,6 +81,7 @@ import { readConnectorPermissionSummaries, readConnectorPermissionSummary, } from '@/lib/knowledge/connectors/permission-config.server' +import { requireConnectorSavedCredentialSetup } from '@/lib/knowledge/connectors/saved-credential-rollout' import { MEMBER_OBSERVATION_STALE_AFTER_HOURS } from '@/lib/knowledge/connectors/sync-limits' import { connectorIsLive } from '@/lib/knowledge/connectors/sync-lock' import { @@ -118,7 +122,12 @@ import { ServiceAccountTokenError } from '@/lib/oauth/credential-service' import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' -import { getConnectorApiKeyConfig, isConnectorCredentialTypeAllowed } from '@/connectors/auth' +import { + connectorAuthModeLabel, + connectorUsesCredential, + getConnectorApiKeyConfig, + isConnectorCredentialTypeAllowed, +} from '@/connectors/auth' import { getConnectorMeta } from '@/connectors/registry' import type { ConnectorAuthConfig } from '@/connectors/types' import { PER_MEMBER_LISTING_CONTEXT } from '@/connectors/utils' @@ -322,12 +331,16 @@ export async function resolveConnectorCredentialAccessToken(input: { connector: { credentialId: input.credentialId, encryptedApiKey: null }, userId: identity.kind === 'oauth' ? identity.userId : input.actingUserId, requestId: input.requestId, + scope: resourceScopeFromOwner(input), sourceConfig: input.sourceConfig, }) } /** Exposes actionable credential refusals without returning raw provider payloads. */ function rethrowConnectorCredentialError(error: unknown): never { + if (error instanceof ConnectorAwsCredentialError && !error.transient) { + throw new OrchestrationError('validation', error.message) + } if (error instanceof ServiceAccountTokenError && [400, 401, 403].includes(error.statusCode)) { let message: string switch (error.errorCode) { @@ -420,10 +433,7 @@ export async function validateConnectorSourceConfig(input: { */ let tokenUserId = input.actingUserId const apiKeyConfig = getConnectorApiKeyConfig(connectorConfig.auth) - if ( - connectorConfig.auth.mode === 'apiKey' || - (apiKeyConfig && !input.connector.credentialId && input.connector.encryptedApiKey) - ) { + if (!connectorUsesCredential(connectorConfig.auth, input.connector)) { if (!input.connector.encryptedApiKey && !apiKeyConfig?.optional) { return { message: 'API key not found. Please reconfigure the connector.', @@ -464,6 +474,7 @@ export async function validateConnectorSourceConfig(input: { connector: input.connector, userId: tokenUserId, requestId: input.requestId, + scope: resourceScopeFromOwner(input), sourceConfig: input.sourceConfig, }) if (!resolved) { @@ -488,19 +499,21 @@ export async function validateConnectorSourceConfig(input: { ) } } + const secrets = connectorTokenSecrets(resolved) const validation = await connectorConfig .validateConfig(resolved.accessToken, input.sourceConfig, validationContext) .catch((error: unknown) => { const sanitized = toError(error) - sanitized.message = redactKnownSensitiveValues(sanitized.message, [resolved.accessToken]) + sanitized.message = redactKnownSensitiveValues(sanitized.message, secrets) throw sanitized }) return validation.valid ? null : { - message: redactKnownSensitiveValues(validation.error || 'Invalid source configuration', [ - resolved.accessToken, - ]), + message: redactKnownSensitiveValues( + validation.error || 'Invalid source configuration', + secrets + ), errorCode: 'validation', } } @@ -741,6 +754,12 @@ async function executeCreateKnowledgeConnector({ if (input.apiKey && input.credentialId) { throw new OrchestrationError('validation', 'Choose either an OAuth account or an API key') } + await requireConnectorSavedCredentialSetup(connectorMeta.auth, input.credentialId) + /** A key connector's saved credential must be of its declared service, as on an access update. */ + const savedCredentialService = + connectorMeta.auth.mode === 'apiKey' && connectorMeta.auth.savedCredential && input.credentialId + ? requireConnectorCredentialService(connectorMeta) + : undefined if ( context.knowledgeBase.isSearchIndex && (!connectorMeta.search || !input.accessMode || input.accessMode === 'workspace') @@ -840,6 +859,7 @@ async function executeCreateKnowledgeConnector({ ...owner, actingUserId, requestId, + ...(savedCredentialService ? { service: savedCredentialService } : {}), auth: connectorMeta.auth, accessMode: input.accessMode ?? 'workspace', sourceConfig, @@ -890,7 +910,10 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ knowledgeBaseName: context.knowledgeBase.name, connectorType: result.connector.connectorType, syncIntervalMinutes: result.connector.syncIntervalMinutes, - authMode: result.connector.credentialId ? 'oauth' : 'apiKey', + authMode: connectorAuthModeLabel( + getConnectorMeta(result.connector.connectorType)?.auth.mode ?? 'oauth', + Boolean(result.connector.credentialId) + ), accessMode: result.connector.accessMode, }, }, diff --git a/apps/sim/lib/knowledge/connectors/access-token.test.ts b/apps/sim/lib/knowledge/connectors/access-token.test.ts index 0674996d174..afdd14a92e6 100644 --- a/apps/sim/lib/knowledge/connectors/access-token.test.ts +++ b/apps/sim/lib/knowledge/connectors/access-token.test.ts @@ -1,21 +1,30 @@ import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing/mocks/auth-oauth-utils.mock' +import { + internalAwsCredentialsMock, + internalAwsCredentialsMockFns, +} from '@sim/testing/mocks/internal-aws-credentials.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockDecryptApiKey } = vi.hoisted(() => ({ mockDecryptApiKey: vi.fn() })) vi.mock('@/lib/api-key/crypto', () => ({ decryptApiKey: mockDecryptApiKey })) vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) +vi.mock('@/lib/internal/aws/credentials', () => internalAwsCredentialsMock) import type { ConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes' import { + ConnectorAwsCredentialError, connectorServiceAccountScopes, connectorServiceAccountSubject, + createConnectorRunToken, resolveConnectorAccessToken, syncContextForToken, } from '@/lib/knowledge/connectors/access-token' import { isConnectorCredentialTypeAllowed } from '@/connectors/auth' +import { readAwsSession } from '@/connectors/aws-session' import { gmailConnectorMeta } from '@/connectors/gmail/meta' import { googleCalendarConnectorMeta } from '@/connectors/google-calendar/meta' +import { s3ConnectorMeta } from '@/connectors/s3/meta' import type { ConnectorAuthConfig } from '@/connectors/types' const { @@ -32,6 +41,8 @@ const OAUTH_AUTH: ConnectorAuthConfig = { const NO_CREDENTIAL = { credentialId: null, encryptedApiKey: null } +const WORKSPACE_SCOPE = { kind: 'workspace', workspaceId: 'workspace-1' } as const + function credentialConnector(credentialId: string) { return { credentialId, encryptedApiKey: null } } @@ -62,6 +73,7 @@ describe('resolveConnectorAccessToken', () => { connector: credentialConnector('docusign-credential'), userId: 'user-1', requestId: 'req-1', + scope: WORKSPACE_SCOPE, sourceConfig: {}, }) @@ -83,6 +95,7 @@ describe('resolveConnectorAccessToken', () => { connector: { credentialId: null, encryptedApiKey: 'cipher' }, userId: 'user-1', requestId: 'req-1', + scope: WORKSPACE_SCOPE, sourceConfig: {}, }) ).rejects.toThrow('missing credential ID') @@ -96,6 +109,7 @@ describe('resolveConnectorAccessToken', () => { connector: NO_CREDENTIAL, userId: 'user-1', requestId: 'req-1', + scope: WORKSPACE_SCOPE, sourceConfig: {}, }) ).rejects.toThrow('missing encrypted API key') @@ -108,6 +122,7 @@ describe('resolveConnectorAccessToken', () => { connector: NO_CREDENTIAL, userId: 'user-1', requestId: 'req-1', + scope: WORKSPACE_SCOPE, sourceConfig: {}, }) ).rejects.toThrow('missing credential ID') @@ -124,12 +139,110 @@ describe('resolveConnectorAccessToken', () => { connector: credentialConnector('credential-1'), userId: 'credential-owner', requestId: 'req-1', + scope: WORKSPACE_SCOPE, sourceConfig: {}, }) ).resolves.toBeNull() }) }) +describe('saved AWS credential on a key connector', () => { + const session = { + accessKeyId: 'ASIAIOSFODNN7EXAMPLE', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + expiration: new Date('2026-11-06T13:00:00Z'), + } + + beforeEach(() => { + mockDecryptApiKey.mockResolvedValue({ decrypted: 'inline-secret-key' }) + internalAwsCredentialsMockFns.mockResolveAuthorizedAwsCredential.mockReset() + }) + + it('asks for the credential in the knowledge base scope and seeds its session, not a bearer token', async () => { + internalAwsCredentialsMockFns.mockResolveAuthorizedAwsCredential.mockResolvedValueOnce(session) + const organizationScope = { kind: 'organization', organizationId: 'org-1' } as const + + const token = await resolveConnectorAccessToken({ + auth: s3ConnectorMeta.auth, + connector: credentialConnector('aws-credential'), + userId: 'user-1', + requestId: 'sync-run', + scope: organizationScope, + sourceConfig: {}, + }) + if (!token) throw new Error('Expected an AWS connector token') + + const [credentialId, context] = + internalAwsCredentialsMockFns.mockResolveAuthorizedAwsCredential.mock.calls[0] + expect(credentialId).toBe('aws-credential') + expect(context).toMatchObject({ organizationId: 'org-1' }) + expect(context.workspaceId).toBeUndefined() + expect(token.expiresAt).toEqual(session.expiration) + expect(token.accessToken).toBe('') + expect(readAwsSession(syncContextForToken(token))).toEqual({ + accessKeyId: session.accessKeyId, + secretAccessKey: session.secretAccessKey, + sessionToken: session.sessionToken, + }) + expect(mockResolveTokenBundle).not.toHaveBeenCalled() + }) + + it('keeps a stored-key S3 connector on its key without touching AWS', async () => { + const token = await resolveConnectorAccessToken({ + auth: s3ConnectorMeta.auth, + connector: { credentialId: null, encryptedApiKey: 'encrypted' }, + userId: 'user-1', + requestId: 'sync-run', + scope: WORKSPACE_SCOPE, + sourceConfig: {}, + }) + + expect(token).toEqual({ accessToken: 'inline-secret-key' }) + expect(internalAwsCredentialsMockFns.mockResolveAuthorizedAwsCredential).not.toHaveBeenCalled() + }) + + it('reports an unusable AWS credential without leaking its keys', async () => { + internalAwsCredentialsMockFns.mockResolveAuthorizedAwsCredential.mockRejectedValueOnce( + Object.assign(new Error('Not authorized to perform sts:AssumeRoleWithWebIdentity'), { + name: 'AccessDenied', + }) + ) + + await expect( + resolveConnectorAccessToken({ + auth: s3ConnectorMeta.auth, + connector: credentialConnector('aws-credential'), + userId: 'user-1', + requestId: 'sync-run', + scope: WORKSPACE_SCOPE, + sourceConfig: {}, + }) + ).rejects.toMatchObject({ name: 'ConnectorAwsCredentialError', transient: false }) + }) + + it('marks an AWS outage as transient rather than a refused credential', async () => { + internalAwsCredentialsMockFns.mockResolveAuthorizedAwsCredential.mockRejectedValueOnce( + Object.assign(new Error('Rate exceeded'), { + name: 'ThrottlingException', + $metadata: { httpStatusCode: 400 }, + }) + ) + + const failure = await resolveConnectorAccessToken({ + auth: s3ConnectorMeta.auth, + connector: credentialConnector('aws-credential'), + userId: 'user-1', + requestId: 'sync-run', + scope: WORKSPACE_SCOPE, + sourceConfig: {}, + }).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(ConnectorAwsCredentialError) + expect(failure).toMatchObject({ transient: true }) + }) +}) + describe('connectorServiceAccountSubject', () => { const withSubject: ConnectorAuthConfig = { ...OAUTH_AUTH, @@ -166,6 +279,7 @@ describe('delegated connector access', () => { connector: credentialConnector('service-credential'), userId: 'actor', requestId: 'request', + scope: WORKSPACE_SCOPE, sourceConfig: { adminEmail: 'admin@example.com' }, }) } @@ -273,6 +387,7 @@ describe.each([ connector: credentialConnector('google-service'), userId: 'actor', requestId: 'request', + scope: WORKSPACE_SCOPE, sourceConfig: { adminEmail: ' Directory.Admin@Example.com ', scopes: ['untrusted.write'], @@ -349,6 +464,7 @@ describe('impersonation on the connector path', () => { connector: credentialConnector('credential-1'), userId: 'credential-owner', requestId: 'req-1', + scope: WORKSPACE_SCOPE, sourceConfig: { adminEmail: 'admin@corp.com' }, }) ).resolves.toEqual({ accessToken: 'access-token' }) @@ -362,3 +478,92 @@ describe('impersonation on the connector path', () => { ) }) }) + +describe('createConnectorRunToken', () => { + const NOW = new Date('2026-11-06T12:00:00Z') + const session = (accessKeyId: string, expiresAt: Date) => ({ + accessToken: 'aws-credential', + awsSession: { accessKeyId, secretAccessKey: `${accessKeyId}-secret`, sessionToken: 'token' }, + expiresAt, + }) + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + }) + + it('keeps an STS session that has time left instead of exchanging it on every page', async () => { + const resolve = vi.fn() + const syncContext: Record = {} + const runToken = createConnectorRunToken({ + token: session('ASIA1', new Date(NOW.getTime() + 30 * 60_000)), + syncContext, + resolve, + }) + + await runToken.refresh() + + expect(resolve).not.toHaveBeenCalled() + expect(runToken.current().awsSession?.accessKeyId).toBe('ASIA1') + }) + + it('replaces a session close to expiry and signs later calls with the new one', async () => { + const renewed = session('ASIA2', new Date(NOW.getTime() + 60 * 60_000)) + const syncContext: Record = {} + const runToken = createConnectorRunToken({ + token: session('ASIA1', new Date(NOW.getTime() + 60_000)), + syncContext, + resolve: async () => renewed, + }) + + await runToken.refresh() + + expect(runToken.current()).toBe(renewed) + expect(readAwsSession(syncContext)?.accessKeyId).toBe('ASIA2') + }) + + it('re-resolves a token that states no expiry on every refresh', async () => { + const resolve = vi.fn(async () => ({ accessToken: 'fresh-oauth-token' })) + const runToken = createConnectorRunToken({ + token: { accessToken: 'oauth-token' }, + syncContext: {}, + resolve, + }) + + await runToken.refresh() + + expect(resolve).toHaveBeenCalledTimes(1) + expect(runToken.current().accessToken).toBe('fresh-oauth-token') + }) + + it('keeps long-lived saved AWS keys for the whole run instead of reloading them on every page', async () => { + const resolve = vi.fn() + const runToken = createConnectorRunToken({ + token: { + accessToken: '', + awsSession: { accessKeyId: 'AKIA1', secretAccessKey: 'AKIA1-secret' }, + }, + syncContext: {}, + resolve, + }) + + await runToken.refresh() + + expect(resolve).not.toHaveBeenCalled() + expect(runToken.current().awsSession?.accessKeyId).toBe('AKIA1') + }) + + it('never re-resolves a stored key', async () => { + const resolve = vi.fn() + const runToken = createConnectorRunToken({ + token: { accessToken: 'inline-key' }, + syncContext: {}, + resolve: undefined, + }) + + await runToken.refresh() + + expect(resolve).not.toHaveBeenCalled() + expect(runToken.current().accessToken).toBe('inline-key') + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/access-token.ts b/apps/sim/lib/knowledge/connectors/access-token.ts index 0464a520749..2c0027906fd 100644 --- a/apps/sim/lib/knowledge/connectors/access-token.ts +++ b/apps/sim/lib/knowledge/connectors/access-token.ts @@ -1,7 +1,12 @@ import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import { decryptApiKey } from '@/lib/api-key/crypto' -import { resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { + type ResourceScope, + resourceScopeFields, + resourceScopeFromOwner, +} from '@/lib/core/resource-scope' import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' +import { awsCredentialFailureMessage, isTransientAwsFailure } from '@/lib/internal/aws/failure' import type { ConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes' import { getServiceAccountToken, @@ -9,7 +14,12 @@ import { resolveOAuthAccountId, } from '@/lib/oauth/credential-service' import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' -import { getConnectorApiKeyConfig } from '@/connectors/auth' +import { + connectorUsesCredential, + getConnectorApiKeyConfig, + getConnectorCredentialService, +} from '@/connectors/auth' +import { awsSessionSyncContext, type ConnectorAwsSession } from '@/connectors/aws-session' import type { ConnectorAuthConfig } from '@/connectors/types' /** @@ -36,6 +46,83 @@ export interface ConnectorAccessToken { instanceUrl?: string /** Server-only capability bound to this credential and the connector's declared scopes. */ getDelegatedAccessToken?: (subject: string, signal?: AbortSignal) => Promise + /** + * AWS only — the keys a saved AWS credential resolved to for the knowledge + * base's scope. A saved AWS credential has no bearer token, so `accessToken` + * is empty and the connector signs with these instead. + */ + awsSession?: ConnectorAwsSession + /** When the credential stops working, if it says; a holder may reuse it until shortly before. */ + expiresAt?: Date +} + +/** + * Every secret a resolved token carries, for redacting provider text that may + * echo one back. A saved AWS credential's secrets sit in `awsSession`, not in + * the empty bearer, so callers redact this list rather than `accessToken` alone. + */ +export function connectorTokenSecrets(token: ConnectorAccessToken): string[] { + return [ + token.accessToken, + ...(token.awsSession + ? [token.awsSession.secretAccessKey, token.awsSession.sessionToken ?? ''] + : []), + ] +} + +/** The connector-facing service that a saved AWS credential authenticates. */ +const AWS_CREDENTIAL_SERVICE = 'aws' + +/** + * A saved AWS credential that could not be used: unavailable in this scope, + * refused by AWS STS, or not resolvable while AWS was briefly unavailable + * (`transient`). The message is the AWS reason, never a secret. + */ +export class ConnectorAwsCredentialError extends Error { + readonly transient: boolean + + constructor(message: string, options: { cause: unknown; transient: boolean }) { + super(message, { cause: options.cause }) + this.name = 'ConnectorAwsCredentialError' + this.transient = options.transient + } +} + +/** + * Resolves a saved AWS credential to signing keys for the knowledge base's own + * scope. The owner check and the STS exchange are the same ones AWS tools use, + * so a credential from another workspace or organization never resolves here. + */ +async function resolveConnectorAwsToken( + credentialId: string, + scope: ResourceScope, + requestId: string, + signal: AbortSignal | undefined +): Promise { + const { resolveAuthorizedAwsCredential } = await import('@/lib/internal/aws/credentials') + let session: Awaited> + try { + session = await resolveAuthorizedAwsCredential( + credentialId, + { ...resourceScopeFields(scope), executionId: requestId }, + signal + ) + } catch (error) { + signal?.throwIfAborted() + throw new ConnectorAwsCredentialError(awsCredentialFailureMessage(error), { + cause: error, + transient: isTransientAwsFailure(error), + }) + } + return { + accessToken: '', + awsSession: { + accessKeyId: session.accessKeyId, + secretAccessKey: session.secretAccessKey, + ...(session.sessionToken ? { sessionToken: session.sessionToken } : {}), + }, + ...(session.expiration ? { expiresAt: session.expiration } : {}), + } } /** @@ -95,6 +182,11 @@ export async function resolveConnectorAccessToken(params: { connector: { credentialId: string | null; encryptedApiKey: string | null } userId: string requestId: string + /** + * The knowledge base's owner. A credential that resolves per scope (a saved + * AWS credential) is loaded only from this workspace or organization. + */ + scope: ResourceScope /** * Where a service-account credential's impersonation subject lives. Always * passed on a path that has it. Without a subject, a Drive service account @@ -108,10 +200,7 @@ export async function resolveConnectorAccessToken(params: { params.signal?.throwIfAborted() const apiKeyConfig = getConnectorApiKeyConfig(auth) - if ( - auth.mode === 'apiKey' || - (apiKeyConfig && !connector.credentialId && connector.encryptedApiKey) - ) { + if (!connectorUsesCredential(auth, connector)) { if (!connector.encryptedApiKey) { if (apiKeyConfig?.optional) return { accessToken: '' } throw new Error('API key connector is missing encrypted API key') @@ -124,6 +213,13 @@ export async function resolveConnectorAccessToken(params: { throw new Error('OAuth connector is missing credential ID') } + if (getConnectorCredentialService(auth) === AWS_CREDENTIAL_SERVICE) { + return resolveConnectorAwsToken(connector.credentialId, params.scope, requestId, params.signal) + } + if (auth.mode !== 'oauth') { + throw new Error('This connector cannot authenticate with a saved credential') + } + const subject = connectorServiceAccountSubject(auth, params.sourceConfig) const githubRepositoryScope = auth.mode === 'oauth' && auth.provider === 'github-repositories' @@ -239,5 +335,43 @@ export function syncContextForToken(token: ConnectorAccessToken): Record +} + +/** + * Holds the token one connector run syncs with. Each refresh re-resolves it, so a long run + * outlives a short-lived token, and re-seeds what the token binds into the run's + * `syncContext`, so a connector reading its credential's site or AWS session from there signs + * with the current one. A token that states its expiry (an AWS STS session) is kept until + * shortly before it rather than exchanged again on every page. Long-lived saved AWS keys (an + * AWS session with no session token) are kept for the whole run, like a stored key, for which + * `resolve` is absent. + */ +export function createConnectorRunToken(input: { + token: ConnectorAccessToken + syncContext: Record + resolve: (() => Promise) | undefined +}): ConnectorRunToken { + let token = input.token + return { + current: () => token, + async refresh() { + if (!input.resolve) return + if (token.awsSession && !token.awsSession.sessionToken) return + if (token.expiresAt && token.expiresAt.getTime() - Date.now() > TOKEN_REUSE_MARGIN_MS) return + token = await input.resolve() + Object.assign(input.syncContext, syncContextForToken(token)) + }, } } diff --git a/apps/sim/lib/knowledge/connectors/credential-service.ts b/apps/sim/lib/knowledge/connectors/credential-service.ts new file mode 100644 index 00000000000..ecc19537b64 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/credential-service.ts @@ -0,0 +1,33 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getServiceConfigByProviderId, + getServiceConfigByServiceId, + type ServiceProviderIdentity, +} from '@/lib/oauth' +import { getConnectorCredentialService } from '@/connectors/auth' +import type { ConnectorMeta } from '@/connectors/types' + +/** + * The OAuth service a connector's credential must belong to. Setting up a key + * connector on its saved credential and every access update resolve it here, so + * a connector whose declared service has no config is refused instead of + * skipping the provider check. Callers reach it only for a connector that takes + * a credential, so a missing service is a connector-definition fault, not one + * the caller can fix. + */ +export function requireConnectorCredentialService( + connectorMeta: Pick +): ServiceProviderIdentity { + const credentialService = getConnectorCredentialService(connectorMeta.auth) + const service = credentialService + ? (getServiceConfigByServiceId(credentialService) ?? + getServiceConfigByProviderId(credentialService)) + : null + if (!service) { + throw new OrchestrationError( + 'internal', + `${connectorMeta.name} has no OAuth service to validate the credential against` + ) + } + return service +} diff --git a/apps/sim/lib/knowledge/connectors/external-group-sync.ts b/apps/sim/lib/knowledge/connectors/external-group-sync.ts index 13d94d92396..91b77e52d90 100644 --- a/apps/sim/lib/knowledge/connectors/external-group-sync.ts +++ b/apps/sim/lib/knowledge/connectors/external-group-sync.ts @@ -629,6 +629,7 @@ export async function refreshConnectorDirectory( connector, userId: credentialUserId, requestId, + scope: resourceScopeFromOwner(connector), sourceConfig, }) if (!token) return 'unusable' diff --git a/apps/sim/lib/knowledge/connectors/member-sync-engine.ts b/apps/sim/lib/knowledge/connectors/member-sync-engine.ts index 2d8318add4f..3e5ffe7088b 100644 --- a/apps/sim/lib/knowledge/connectors/member-sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/member-sync-engine.ts @@ -1657,6 +1657,7 @@ async function syncDedicatedMemberContent(input: { connector, userId, requestId: run.runId, + scope: resourceScopeFromOwner(run), sourceConfig, }) if (!token) throw new Error('The content credential needs to be reconnected') diff --git a/apps/sim/lib/knowledge/connectors/saved-credential-rollout.test.ts b/apps/sim/lib/knowledge/connectors/saved-credential-rollout.test.ts new file mode 100644 index 00000000000..d18540824c6 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/saved-credential-rollout.test.ts @@ -0,0 +1,44 @@ +/** + * Pins the setup gate for a key connector's saved-credential alternative. Workers from an + * earlier release treat a key connector without a key as one whose credential was removed and + * unschedule it, so no such connector may be set up until the switch is turned on. + */ +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +import { requireConnectorSavedCredentialSetup } from '@/lib/knowledge/connectors/saved-credential-rollout' +import { databricksConnectorMeta } from '@/connectors/databricks/meta' +import { s3ConnectorMeta } from '@/connectors/s3/meta' + +const mockIsFeatureEnabled = featureFlagsMockFns.mockIsFeatureEnabled + +describe('requireConnectorSavedCredentialSetup', () => { + beforeEach(() => { + mockIsFeatureEnabled.mockReset() + }) + + it('refuses to bind a saved credential to S3 while setup is off', async () => { + mockIsFeatureEnabled.mockResolvedValueOnce(false) + + await expect( + requireConnectorSavedCredentialSetup(s3ConnectorMeta.auth, 'aws-credential') + ).rejects.toThrow(/cannot use a saved credential yet/) + }) + + it('allows a saved credential once setup is on', async () => { + mockIsFeatureEnabled.mockResolvedValueOnce(true) + + await expect( + requireConnectorSavedCredentialSetup(s3ConnectorMeta.auth, 'aws-credential') + ).resolves.toBeUndefined() + }) + + it('leaves key setup, and key-only connectors, untouched by the switch', async () => { + await requireConnectorSavedCredentialSetup(s3ConnectorMeta.auth, undefined) + await requireConnectorSavedCredentialSetup(databricksConnectorMeta.auth, 'stray-credential') + + expect(mockIsFeatureEnabled).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/saved-credential-rollout.ts b/apps/sim/lib/knowledge/connectors/saved-credential-rollout.ts new file mode 100644 index 00000000000..3f10b42fa70 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/saved-credential-rollout.ts @@ -0,0 +1,31 @@ +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { ConnectorAuthConfig } from '@/connectors/types' + +/** + * Whether a key-based connector may be set up with its saved-credential alternative. + * + * A global switch that gates setup only. Workers from an older release read a key + * connector without a key as one whose credential was removed and unschedule it, so + * the switch stays off until every app and worker runs a release that reads these rows. + * Turning it off later does not protect rows already set up: once any exist, a release + * rollback unschedules them as credential-removed, and they stay idle until edited or + * synced manually. + */ +export function isConnectorSavedCredentialSetupEnabled(): Promise { + return isFeatureEnabled('knowledge-connector-saved-credentials') +} + +/** Refuses to bind a saved credential to a key-based connector while setup is not enabled. */ +export async function requireConnectorSavedCredentialSetup( + auth: ConnectorAuthConfig, + credentialId: string | null | undefined +): Promise { + if (auth.mode !== 'apiKey' || !auth.savedCredential || !credentialId) return + if (!(await isConnectorSavedCredentialSetupEnabled())) { + throw new OrchestrationError( + 'validation', + 'This source cannot use a saved credential yet. Use its API key instead.' + ) + } +} diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 6fcf9c9f905..2265263d505 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -20,7 +20,11 @@ import { type BillingAttributionSnapshot, } from '@/lib/billing/core/attribution' import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server' -import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { + type ResourceScope, + resourceScopeFields, + resourceScopeFromOwner, +} from '@/lib/core/resource-scope' import { EMPTY_ACL } from '@/lib/knowledge/access/tokens' import type { MirroredDocumentAcl } from '@/lib/knowledge/access/types' import { @@ -32,6 +36,7 @@ import { } from '@/lib/knowledge/connectors/access-modes' import { type ConnectorAccessToken, + createConnectorRunToken, resolveConnectorAccessToken, resolveConnectorTokenUserId, syncContextForToken, @@ -99,7 +104,7 @@ import { hardDeleteDocuments } from '@/lib/knowledge/documents/service' import { getRetryAfterMs, isRateLimitError } from '@/lib/knowledge/documents/utils' import { getCredentialTerminalRefreshError } from '@/lib/oauth/credential-service' import { isCredentialRevocationError } from '@/lib/oauth/refresh-error-codes' -import { connectorHasAuthSource } from '@/connectors/auth' +import { connectorHasAuthSource, connectorUsesCredential } from '@/connectors/auth' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' import type { ConnectorAuthConfig, @@ -799,7 +804,8 @@ async function resolveAccessToken( connectorConfig: { auth: ConnectorAuthConfig }, userId: string, sourceConfig: Record, - accessMode: ContentEngineAccessMode + accessMode: ContentEngineAccessMode, + scope: ResourceScope ): Promise { const requestId = `sync-${connector.credentialId}` const resolved = await resolveConnectorAccessToken({ @@ -808,6 +814,7 @@ async function resolveAccessToken( connector, userId, requestId, + scope, sourceConfig, }) @@ -1108,25 +1115,15 @@ export async function executeSync( ) } - let credentialToken = await resolveAccessToken( + const kbScope = resourceScopeFromOwner(kbOwner) + const initialToken = await resolveAccessToken( connector, connectorConfig, credentialUserId, sourceConfig, - accessMode + accessMode, + kbScope ) - /** Re-resolves the token for every OAuth call after the first, so a long run outlives a short-lived token. */ - const refreshOAuthToken = async (): Promise => { - if (connectorConfig.auth.mode === 'oauth') { - credentialToken = await resolveAccessToken( - connector, - connectorConfig, - credentialUserId, - sourceConfig, - accessMode - ) - } - } /** * A credential that already knows its cloud id seeds the same `syncContext` @@ -1137,13 +1134,31 @@ export async function executeSync( */ const syncContext: Record = { syncRunId: generateId(), - ...syncContextForToken(credentialToken), + ...syncContextForToken(initialToken), /** Tells a connector to carry permissions with its listing; without it, none are read. */ ...(mirrored ? { mirrorsSourceAcls: true } : {}), } if (mirrored) await connectorConfig.permissionConfig?.populateSyncContext(connectorId, syncContext) + /** Re-resolved before every call after the first, so a long run outlives a short-lived token. */ + const runToken = createConnectorRunToken({ + token: initialToken, + syncContext, + resolve: connectorUsesCredential(connectorConfig.auth, connector) + ? () => + resolveAccessToken( + connector, + connectorConfig, + credentialUserId, + sourceConfig, + accessMode, + kbScope + ) + : undefined, + }) + const refreshRunToken = () => runToken.refresh() + // Shared cutoff for both the tombstone-retry bound below and the stuck-document // retry near the end of this sync — same RETRY_WINDOW_DAYS window, one computation. const retryCutoff = new Date(Date.now() - RETRY_WINDOW_DAYS * 24 * 60 * 60 * 1000) @@ -1276,7 +1291,7 @@ export async function executeSync( connectorConfig, sourceConfig, syncContext, - accessToken: credentialToken.accessToken, + accessToken: runToken.current().accessToken, force: Boolean(options.fullSync) || connector.consecutiveFailures > 0 || @@ -1296,15 +1311,15 @@ export async function executeSync( result, forceRehydrate, getAccessToken: async (pageNum) => { - if (pageNum > 0) await refreshOAuthToken() - return credentialToken.accessToken + if (pageNum > 0) await refreshRunToken() + return runToken.current().accessToken }, hydration: { concurrency: connectorConfig.contentConcurrency, - beforeHydration: refreshOAuthToken, + beforeHydration: refreshRunToken, getDocument: (externalId) => connectorConfig.getDocument( - credentialToken.accessToken, + runToken.current().accessToken, sourceConfig, externalId, syncContext @@ -1332,7 +1347,7 @@ export async function executeSync( connectorConfig, sourceConfig, syncContext, - accessToken: credentialToken.accessToken, + accessToken: runToken.current().accessToken, externalDocs, generationStartedAt, ownedExternalIds: [], diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts index 05efa5d5d21..cd36618dd2b 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -34,12 +34,14 @@ const { mockEncryptApiKey, mockValidateGitHub, + mockValidateS3, mockEnqueueConnectorDeletion, mockEnqueueConnectorDetachment, } = vi.hoisted(() => ({ mockDispatchSync: vi.fn(), mockEncryptApiKey: vi.fn(), mockValidateGitHub: vi.fn(), + mockValidateS3: vi.fn(), mockEnqueueConnectorDeletion: vi.fn(), mockEnqueueConnectorDetachment: vi.fn(), })) @@ -76,6 +78,16 @@ vi.mock('@/connectors/registry.server', () => ({ configFields: [], validateConfig: mockValidateGitHub, }, + s3: { + name: 'Amazon S3', + auth: { + mode: 'apiKey', + label: 'Secret Access Key', + savedCredential: { provider: 'aws', label: 'Saved AWS credential' }, + }, + configFields: [], + validateConfig: mockValidateS3, + }, notion: { auth: { mode: 'apiKey', optional: true }, configFields: [], @@ -188,6 +200,35 @@ describe('performCreateKnowledgeConnector', () => { } ) + it.each(['result', 'exception'])( + 'redacts a saved AWS session from provider validation %s errors', + async (failure) => { + const awsSession = { + accessKeyId: 'ASIAEXAMPLE', + secretAccessKey: 'session/secret', + sessionToken: 'session-token', + } + const message = `Denied ${awsSession.secretAccessKey} ${awsSession.sessionToken}` + if (failure === 'result') + mockValidateS3.mockResolvedValueOnce({ valid: false, error: message }) + else mockValidateS3.mockRejectedValueOnce(new OrchestrationError('validation', message)) + const request = performCreateKnowledgeConnector({ + ...createParams, + connectorType: 's3', + credentialId: 'aws-credential', + resolveAccessToken: vi.fn().mockResolvedValue({ accessToken: '', awsSession }), + }) + const expected = { errorCode: 'validation', error: 'Denied [REDACTED] [REDACTED]' } + if (failure === 'result') await expect(request).resolves.toMatchObject(expected) + else + await expect(request).rejects.toMatchObject({ + code: expected.errorCode, + message: expected.error, + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + } + ) + it('validates and encrypts a GitHub PAT without resolving an OAuth account or returning the secret', async () => { dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'kb-1' }]) dbChainMockFns.returning.mockResolvedValueOnce([ diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 058439ab50f..a8c0e49e30b 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -43,6 +43,7 @@ import { } from '@/lib/knowledge/connectors/access-modes' import { type ConnectorAccessToken, + connectorTokenSecrets, syncContextForToken, } from '@/lib/knowledge/connectors/access-token' import { enqueueConnectorDeletion } from '@/lib/knowledge/connectors/deletion' @@ -72,7 +73,11 @@ import { lockOrganizationSearchApproval } from '@/lib/knowledge/search/integrati import { createTagDefinition } from '@/lib/knowledge/tags/service' import { captureServerEvent } from '@/lib/posthog/server' import { searchSourceIdentity } from '@/lib/sim-search/source-identity' -import { getConnectorApiKeyConfig } from '@/connectors/auth' +import { + connectorAuthModeLabel, + connectorUsesCredential, + getConnectorApiKeyConfig, +} from '@/connectors/auth' import { getConnectorMeta } from '@/connectors/registry' import { findSourceSelectionError } from '@/connectors/selection' import { PER_MEMBER_LISTING_CONTEXT } from '@/connectors/utils' @@ -296,6 +301,7 @@ export async function performCreateKnowledgeConnector( let resolvedCredentialId: string | null = null let resolvedEncryptedApiKey: string | null = null let accessToken: string | null = null + let tokenSecrets: string[] = [] let tokenContext: Record = {} const apiKeyConfig = getConnectorApiKeyConfig(connectorConfig.auth) if (apiKey && (!apiKeyConfig || accessMode === 'members')) { @@ -304,7 +310,10 @@ export async function performCreateKnowledgeConnector( if (apiKey && credentialId) { return fail('Choose either an OAuth account or an API key', 'validation') } - const usesApiKey = connectorConfig.auth.mode === 'apiKey' || Boolean(apiKeyConfig && apiKey) + const usesApiKey = !connectorUsesCredential(connectorConfig.auth, { + credentialId: credentialId ?? null, + encryptedApiKey: apiKey ?? null, + }) if (membersBinding) { /** @@ -331,6 +340,7 @@ export async function performCreateKnowledgeConnector( return fail('API key is required', 'validation') } accessToken = apiKey ?? '' + tokenSecrets = [accessToken] } else if (!membersBinding || credentialId) { if (!credentialId) { return fail('Credential is required', 'validation') @@ -345,6 +355,7 @@ export async function performCreateKnowledgeConnector( return fail('Credential has no access token. Please reconnect your account.', 'validation') } accessToken = token.accessToken + tokenSecrets = connectorTokenSecrets(token) tokenContext = syncContextForToken(token) resolvedCredentialId = credentialId } @@ -360,13 +371,13 @@ export async function performCreateKnowledgeConnector( .validateConfig(accessToken, sourceConfig, validationContext) .catch((error: unknown) => { const sanitized = toError(error) - sanitized.message = redactKnownSensitiveValues(sanitized.message, [accessToken]) + sanitized.message = redactKnownSensitiveValues(sanitized.message, tokenSecrets) throw sanitized }) if (!configValidation.valid) { return fail( (configValidation.error && - redactKnownSensitiveValues(configValidation.error, [accessToken])) || + redactKnownSensitiveValues(configValidation.error, tokenSecrets)) || `The ${connectorType} connector rejected sourceConfig without a reason — re-check its required fields with \`sim connector-types list --detail full\` (GET /api/v2/connector-types?detail=full) before retrying; the same config will fail again.`, 'validation' ) @@ -642,7 +653,7 @@ export async function performCreateKnowledgeConnector( knowledgeBaseName: kb.name, connectorType, syncIntervalMinutes, - authMode: usesApiKey ? 'apiKey' : 'oauth', + authMode: connectorAuthModeLabel(connectorConfig.auth.mode, !usesApiKey), }, ...(request ? { request } : {}), }) diff --git a/apps/sim/lib/mothership/jobs/lane.integration.ts b/apps/sim/lib/mothership/jobs/lane.integration.ts index 90e3691fcd6..3f48510b313 100644 --- a/apps/sim/lib/mothership/jobs/lane.integration.ts +++ b/apps/sim/lib/mothership/jobs/lane.integration.ts @@ -165,6 +165,7 @@ import { jobOccurrenceId, MIN_JOB_CADENCE_MS } from '@/lib/mothership/jobs/occur import { JOB_DISPATCH_GRACE_MS, reconcileJobRuns } from '@/lib/mothership/jobs/reconcile' import { takeOverAbandonedRuns } from '@/lib/mothership/request/application/abandoned-runs' import { startUnattendedTurn } from '@/lib/mothership/request/lifecycle/unattended' +import { readEvents } from '@/lib/mothership/request/session/buffer' import { chatStreamLockKey, StreamControllerSupersededError, @@ -506,6 +507,14 @@ describe.runIf(Boolean(redisUrl))('the scheduled job lane', () => { ]) await runJobLane(new Date()) await waitFor(async () => worker.open.has(occurrenceId), 'the first leg') + /** The worker has written the text, but the process must persist it before it dies. */ + await waitFor( + async () => + (await readEvents(occurrenceId, '0')).some( + (event) => event.type === 'text' && event.payload.text === 'Started triage. ' + ), + 'the first leg text to persist before the process dies' + ) controllers.get(occurrenceId)?.abort(new StreamControllerSupersededError()) worker.open.get(occurrenceId)?.response.socket?.destroy() diff --git a/apps/sim/lib/mothership/request/tools/executor.test.ts b/apps/sim/lib/mothership/request/tools/executor.test.ts index 54ab87325d8..1251f922207 100644 --- a/apps/sim/lib/mothership/request/tools/executor.test.ts +++ b/apps/sim/lib/mothership/request/tools/executor.test.ts @@ -266,14 +266,14 @@ describe('pendingToolWaitBudgetMs', () => { }) it('matches the requested browser_wait_for renderer budget', () => { - expect(pendingToolWaitBudgetMs({ name: 'browser_wait_for', status: 'executing' })).toBe(85_000) + expect(pendingToolWaitBudgetMs({ name: 'browser_wait_for', status: 'executing' })).toBe(100_000) expect( pendingToolWaitBudgetMs({ name: 'browser_wait_for', status: 'executing', params: { timeoutMs: 120_000 }, }) - ).toBe(195_000) + ).toBe(210_000) }) it('outlasts the wait window the desktop holds a terminal run for', () => { diff --git a/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts index 23e28acb53f..da54bb28142 100644 --- a/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts +++ b/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts @@ -1289,11 +1289,11 @@ describe('executeBrowserToolOnClient', () => { * native queue while the desktop is still honoring that same wait. */ it.each([ - ['number', 30_000, 105_000], - ['numeric string', '30000', 105_000], - ['non-numeric', 'soon', 85_000], - ['negative', -5_000, 85_000], - ['above the desktop clamp', 500_000, 195_000], + ['number', 30_000, 120_000], + ['numeric string', '30000', 120_000], + ['non-numeric', 'soon', 100_000], + ['negative', -5_000, 100_000], + ['above the desktop clamp', 500_000, 210_000], ])( 'budgets browser_wait_for above the desktop wait (%s)', async (_label, timeoutMs, expected) => { diff --git a/apps/sim/lib/mothership/transport/receiver.test.ts b/apps/sim/lib/mothership/transport/receiver.test.ts index 7433a7444cc..1c3d227d8aa 100644 --- a/apps/sim/lib/mothership/transport/receiver.test.ts +++ b/apps/sim/lib/mothership/transport/receiver.test.ts @@ -29,7 +29,7 @@ import { receiveSimControls, startSimReceivers } from '@/lib/mothership/transpor const mocks = { fetch: mothershipGoFetchMockFns.mockFetchGo, execute, - sleep: utilsHelpersMockFns.mockSleep, + sleep: utilsHelpersMockFns.mockInterruptibleSleep, } function request() { @@ -232,3 +232,77 @@ describe('outbound receiver lifecycle', () => { } ) }) + +describe('outbound receiver backoff', () => { + const logger = () => getMockLogger('MothershipTransportReceiver') + + beforeEach(() => { + vi.resetAllMocks() + mocks.sleep.mockResolvedValue(undefined) + }) + + /** Runs the receiver over scripted poll outcomes and returns each wait it took. */ + async function delaysFor(outcomes: (() => Response)[]): Promise { + const controller = new AbortController() + let polls = 0 + mocks.fetch.mockImplementation(async () => { + const next = outcomes[polls++] + if (next) return next() + controller.abort() + throw new Error('shutdown') + }) + await receiveSimControls('https://worker.test', 'a'.repeat(64), controller.signal) + for (const [, signal] of mocks.sleep.mock.calls) expect(signal).toBe(controller.signal) + return mocks.sleep.mock.calls.map(([ms]) => ms) + } + + const refused = (status: number) => () => new Response(null, { status }) + const lost = () => { + throw new Error('connection lost') + } + const empty = () => Response.json({ requests: [] }) + + it.each([401, 403])( + 'waits minutes, not seconds, between polls the worker refuses (%i)', + async (status) => { + const delays = await delaysFor(Array.from({ length: 8 }, () => refused(status))) + expect(delays).toHaveLength(8) + expect(Math.min(...delays)).toBeGreaterThanOrEqual(24_000) + expect(delays[7]).toBeGreaterThanOrEqual(480_000) + expect(Math.max(...delays)).toBeLessThanOrEqual(720_000) + expect(logger().warn).toHaveBeenCalledOnce() + } + ) + + it('backs off transient failures to a ceiling under the 5 s run-control deadline, warning once', async () => { + const delays = await delaysFor(Array.from({ length: 8 }, () => lost)) + expect(delays[0]).toBeLessThanOrEqual(1200) + expect(delays[7]).toBeGreaterThanOrEqual(3200) + expect(Math.max(...delays)).toBeLessThan(5000) + expect(logger().warn).toHaveBeenCalledOnce() + }) + + it('starts over after a poll succeeds and says it reconnected', async () => { + const delays = await delaysFor([lost, lost, lost, empty, lost]) + expect(delays).toHaveLength(4) + expect(delays[2]).toBeGreaterThanOrEqual(3200) + expect(delays[3]).toBeLessThanOrEqual(1200) + expect(logger().warn).toHaveBeenCalledTimes(2) + expect(logger().info).toHaveBeenCalledOnce() + }) + + it('keeps backing off while polls succeed but their batches cannot be read', async () => { + const malformed = () => Response.json({ requests: 'not a list' }) + const delays = await delaysFor(Array.from({ length: 5 }, () => malformed)) + expect(delays[4]).toBeGreaterThanOrEqual(3200) + expect(logger().warn).toHaveBeenCalledOnce() + expect(logger().info).not.toHaveBeenCalled() + }) + + it('warns again, and backs off from the start, when an outage turns into a refused key', async () => { + const delays = await delaysFor([lost, lost, lost, refused(401), refused(401)]) + expect(delays[3]).toBeLessThanOrEqual(36_000) + expect(delays[4]).toBeGreaterThanOrEqual(48_000) + expect(logger().warn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/mothership/transport/receiver.ts b/apps/sim/lib/mothership/transport/receiver.ts index ac1892366b8..ee341054c59 100644 --- a/apps/sim/lib/mothership/transport/receiver.ts +++ b/apps/sim/lib/mothership/transport/receiver.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' +import { interruptibleSleep } from '@sim/utils/helpers' +import { type BackoffOptions, backoffWithJitter } from '@sim/utils/retry' import { truncate } from '@sim/utils/string' import { env } from '@/lib/core/config/env' import { isMothershipBenchmarkEnabled } from '@/lib/core/config/env-flags' @@ -22,6 +23,25 @@ import { executeSimControl } from '@/lib/mothership/transport/control' const logger = createLogger('MothershipTransportReceiver') const receivers = new Map() +/** + * A lost connection or a worker deploy: retry within a second, slowing to 4 s, so with jitter a + * healthy deployment is back inside the worker's 5 s run-control deadline. + */ +const TRANSIENT_BACKOFF: BackoffOptions = { baseMs: 1000, maxMs: 4000 } +/** + * The worker rejected this deployment's key, which only an operator can fix: start at 30 s and + * slow to 10 minutes, so a misconfigured deployment polls a few hundred times a day, not every + * second, and still reconnects on its own once the key is accepted. + */ +const REFUSED_BACKOFF: BackoffOptions = { baseMs: 30_000, maxMs: 600_000 } + +/** The worker refused this deployment's key (401/403), as opposed to failing to answer. */ +class TransportKeyRefused extends Error { + constructor(readonly status: number) { + super(`Transport key refused (HTTP ${status})`) + } +} + interface ReceivedControl { id: string answer: () => Promise @@ -61,6 +81,8 @@ export async function receiveSimControls( channelId: string, signal: AbortSignal ): Promise { + let failures = 0 + let outage: 'disconnected' | 'key_refused' | undefined while (!signal.aborted) { let phase: 'poll' | 'parse' | 'reply' = 'poll' try { @@ -75,6 +97,9 @@ export async function receiveSimControls( }) if (!response.ok) { await response.body?.cancel() + if (response.status === 401 || response.status === 403) { + throw new TransportKeyRefused(response.status) + } throw new Error(`Transport poll refused (HTTP ${response.status})`) } phase = 'parse' @@ -101,13 +126,35 @@ export async function receiveSimControls( ) const failed = replies.find((reply) => reply.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason + // Only a whole iteration (poll, batch, replies) ends an outage. + if (outage) { + logger.info('Sim transport reconnected') + outage = undefined + } } catch (error) { if (signal.aborted) return - logger.warn('Sim transport disconnected; reconnecting', { - phase, - error: getErrorMessage(error), - }) - await sleep(1000) + const keyRefused = error instanceof TransportKeyRefused + const kind = keyRefused ? 'key_refused' : 'disconnected' + // One line per outage, and again only when it changes kind, not one per retry. + if (outage !== kind) { + outage = kind + failures = 0 + if (keyRefused) { + logger.warn('Sim transport key refused by the worker; check COPILOT_API_KEY', { + status: error.status, + }) + } else { + logger.warn('Sim transport disconnected; reconnecting', { + phase, + error: getErrorMessage(error), + }) + } + } + failures++ + await interruptibleSleep( + backoffWithJitter(failures, null, keyRefused ? REFUSED_BACKOFF : TRANSIENT_BACKOFF), + signal + ) } } } diff --git a/apps/sim/lib/oauth/apollo.test.ts b/apps/sim/lib/oauth/apollo.test.ts new file mode 100644 index 00000000000..6f929cb43f2 --- /dev/null +++ b/apps/sim/lib/oauth/apollo.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' +import { createApolloProvider, formatApolloAuthorizationUrl } from '@/lib/oauth/apollo' + +const connector = createApolloProvider({ + clientId: 'fixture-client', + clientSecret: 'fixture-secret', + redirectURI: 'https://example.invalid/api/auth/oauth2/callback/apollo', + scopes: ['read_user_profile'], +}) + +describe('Apollo OAuth protocol', () => { + it('preserves state and callback encoding inside Apollo’s authorization fragment', () => { + const callback = 'https://example.invalid/api/auth/oauth2/callback/apollo' + const state = 'fixture+state/with=padding' + const generated = new URL('https://app.apollo.io/#/oauth/authorize') + generated.searchParams.set('client_id', 'fixture-client') + generated.searchParams.set('redirect_uri', callback) + generated.searchParams.set('state', state) + generated.searchParams.set('scope', 'read_user_profile contacts_search') + generated.searchParams.set('response_type', 'code') + + const authorization = new URL(formatApolloAuthorizationUrl(generated.toString())) + expect(authorization.origin).toBe('https://app.apollo.io') + expect(authorization.search).toBe('') + const [path, query] = authorization.hash.slice(1).split('?') + expect(path).toBe('/oauth/authorize') + const params = new URLSearchParams(query) + expect(params.get('state')).toBe(state) + expect(params.get('redirect_uri')).toBe(callback) + expect(params.get('scope')).toBe('read_user_profile contacts_search') + expect(params.get('client_id')).toBe('fixture-client') + }) + + it('refuses an unexpected authorization destination', () => { + expect(() => + formatApolloAuthorizationUrl('https://example.invalid/?state=fixture#/oauth/authorize') + ).toThrow('Invalid Apollo authorization URL') + }) + + it('uses the authenticated profile without asserting that Apollo verified its email', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + id: 'fixture-user', + first_name: 'Example', + last_name: 'User', + email: 'fixture@example.invalid', + }) + ) + ) + if (!connector.getUserInfo) throw new Error('Apollo needs a profile lookup') + const profile = await connector.getUserInfo({ accessToken: 'fixture-access' }) + expect(profile).toMatchObject({ + name: 'Example User', + email: 'fixture@example.invalid', + emailVerified: false, + }) + expect(profile?.id).toMatch(/^fixture-user-/) + }) + + it.each([ + { email: 'fixture@example.invalid' }, + { id: '', email: 'fixture@example.invalid' }, + { id: 'fixture-user' }, + ])('refuses a profile without a usable provider identity and email', async (payload) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(payload))) + if (!connector.getUserInfo) throw new Error('Apollo needs a profile lookup') + await expect(connector.getUserInfo({ accessToken: 'fixture-access' })).rejects.toThrow( + 'Invalid Apollo user profile' + ) + }) + + it('does not expose an upstream error body containing a credential', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ error: 'fixture-access' }, { status: 401 })) + ) + if (!connector.getUserInfo) throw new Error('Apollo needs a profile lookup') + await expect(connector.getUserInfo({ accessToken: 'fixture-access' })).rejects.toThrow( + 'Apollo user profile request failed with HTTP 401' + ) + }) + + it.each([ + () => new Response('{"secret":"fixture-access", malformed json'), + () => + Response.json({ + id: 'fixture-user', + email: 'fixture@example.invalid', + extra: 'x'.repeat(65_537), + }), + ])('refuses malformed or oversized profiles without echoing their bodies', async (response) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response())) + if (!connector.getUserInfo) throw new Error('Apollo needs a profile lookup') + await expect(connector.getUserInfo({ accessToken: 'fixture-access' })).rejects.toThrow( + 'Invalid Apollo user profile' + ) + }) +}) diff --git a/apps/sim/lib/oauth/apollo.ts b/apps/sim/lib/oauth/apollo.ts new file mode 100644 index 00000000000..90b88fe2b6b --- /dev/null +++ b/apps/sim/lib/oauth/apollo.ts @@ -0,0 +1,72 @@ +import { generateId } from '@sim/utils/id' +import type { GenericOAuthConfig } from 'better-auth/plugins' +import { z } from 'zod' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' + +export const APOLLO_TOKEN_URL = 'https://app.apollo.io/api/v1/oauth/token' + +const profileSchema = z.object({ + id: z.string().trim().min(1), + email: z.email(), + first_name: z.string().nullish(), + last_name: z.string().nullish(), +}) + +/** Moves Better Auth's query into the fragment Apollo's client-side authorize page reads. */ +export function formatApolloAuthorizationUrl(value: string): string { + const url = new URL(value) + if ( + url.origin !== 'https://app.apollo.io' || + url.pathname !== '/' || + url.hash !== '#/oauth/authorize' + ) { + throw new Error('Invalid Apollo authorization URL') + } + const query = url.search + url.search = '' + url.hash = `/oauth/authorize${query}` + return url.toString() +} + +/** Connects an Apollo partner app using its documented OAuth and current-user profile endpoints. */ +export function createApolloProvider( + config: Pick +): GenericOAuthConfig { + return { + ...config, + providerId: 'apollo', + authorizationUrl: 'https://app.apollo.io/#/oauth/authorize', + tokenUrl: APOLLO_TOKEN_URL, + authentication: 'post', + getUserInfo: async (tokens) => { + // boundary-raw-fetch: Apollo's external current-user endpoint verifies the OAuth token's identity. + const response = await fetch('https://app.apollo.io/api/v1/users/api_profile', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + redirect: 'error', + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) { + await response.body?.cancel() + throw new Error(`Apollo user profile request failed with HTTP ${response.status}`) + } + let payload: unknown + try { + payload = await readResponseJsonWithLimit(response, { + maxBytes: 64 * 1024, + label: 'Apollo user profile', + }) + } catch { + throw new Error('Invalid Apollo user profile') + } + const parsed = profileSchema.safeParse(payload) + if (!parsed.success) throw new Error('Invalid Apollo user profile') + const profile = parsed.data + return { + id: `${profile.id}-${generateId()}`, + name: [profile.first_name, profile.last_name].filter(Boolean).join(' ') || profile.email, + email: profile.email, + emailVerified: false, + } + }, + } +} diff --git a/apps/sim/lib/oauth/credential-service-cancellation.test.ts b/apps/sim/lib/oauth/credential-service-cancellation.test.ts index a3ee4f787d7..3fe7db1a82f 100644 --- a/apps/sim/lib/oauth/credential-service-cancellation.test.ts +++ b/apps/sim/lib/oauth/credential-service-cancellation.test.ts @@ -2,12 +2,14 @@ import { credential } from '@sim/db/schema' import { createDeferred } from '@sim/testing/helpers/deferred' import { queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, RAMP_SERVICE_ACCOUNT_PROVIDER_ID, + VANTA_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/client-credential-accounts/descriptors' import * as clientAccounts from '@/lib/credentials/client-credential-accounts/server' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { forgetRejectedClientCredentialToken, resolveServiceAccountToken, @@ -152,3 +154,334 @@ describe('client credential token cache', () => { } }) }) + +describe('client credential token refresh after a failed mint', () => { + const refused = () => + new TokenServiceAccountValidationError('invalid_credentials', 401, { step: 'mint' }) + const unavailable = (status = 502, retryAfterMs?: number) => + new TokenServiceAccountValidationError( + 'provider_unavailable', + status, + { step: 'mint' }, + { + retryAfterMs, + } + ) + + /** Resolves with fake timers running, so an in-mint retry pause elapses without real waiting. */ + async function resolveFor( + credentialId: string, + providerId: string = RAMP_SERVICE_ACCOUNT_PROVIDER_ID + ) { + queueTableRows(credential, [storedCredential]) + const outcome = resolveServiceAccountToken(credentialId, providerId).then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + await vi.runAllTimersAsync() + return outcome + } + + function minter() { + const mint = vi.fn() + vi.spyOn(clientAccounts, 'getClientCredentialAccountMinter').mockReturnValue(mint) + return mint + } + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps serving a still-valid token while its early refresh fails transiently', async () => { + const mint = minter() + mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 }) + await expect(resolveFor('ramp-refresh-outage')).resolves.toEqual({ + value: { accessToken: 'current-token' }, + }) + mint.mockRejectedValue(unavailable()) + vi.advanceTimersByTime(6 * 60_000) + + await expect(resolveFor('ramp-refresh-outage')).resolves.toEqual({ + value: { accessToken: 'current-token' }, + }) + const askedDuringRefresh = mint.mock.calls.length + await expect(resolveFor('ramp-refresh-outage')).resolves.toEqual({ + value: { accessToken: 'current-token' }, + }) + expect(askedDuringRefresh).toBeGreaterThan(1) + expect(mint).toHaveBeenCalledTimes(askedDuringRefresh) + }) + + it('reports the failed refresh once the cached token has run out', async () => { + const mint = minter() + mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 }) + await resolveFor('ramp-refresh-expired') + mint.mockRejectedValue(unavailable()) + vi.advanceTimersByTime(600_000 - 20_000) + + await expect(resolveFor('ramp-refresh-expired')).resolves.toEqual({ + error: expect.objectContaining({ code: 'provider_unavailable' }), + }) + }) + + it('does not serve a cached token that a slow failed refresh carried inside its expiry margin', async () => { + const mint = minter() + mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 }) + await resolveFor('ramp-refresh-slow') + mint.mockImplementation(async () => { + vi.setSystemTime(Date.now() + 15_000) + throw unavailable() + }) + vi.advanceTimersByTime(600_000 - 40_000) + + await expect(resolveFor('ramp-refresh-slow')).resolves.toEqual({ + error: expect.objectContaining({ code: 'provider_unavailable' }), + }) + }) + + it('does not serve a cached token the provider refused while its failed refresh was in flight', async () => { + const mint = minter() + mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 }) + await resolveFor('ramp-refresh-rejected-midway') + mint.mockImplementation(async () => { + forgetRejectedClientCredentialToken('current-token') + throw unavailable() + }) + vi.advanceTimersByTime(6 * 60_000) + + await expect(resolveFor('ramp-refresh-rejected-midway')).resolves.toEqual({ + error: expect.objectContaining({ code: 'provider_unavailable' }), + }) + }) + + it('stops serving the cached token once the provider refuses the credentials', async () => { + const mint = minter() + mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 }) + await resolveFor('ramp-refresh-refused') + mint.mockRejectedValue(refused()) + vi.advanceTimersByTime(6 * 60_000) + + await expect(resolveFor('ramp-refresh-refused')).resolves.toEqual({ + error: expect.objectContaining({ code: 'invalid_credentials' }), + }) + expect(mint).toHaveBeenCalledTimes(2) + }) + + it('never serves a refused token again when a later refresh fails with an outage', async () => { + const mint = minter() + mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 }) + await resolveFor('ramp-refused-then-outage') + mint.mockRejectedValueOnce(refused()).mockRejectedValue(unavailable()) + vi.advanceTimersByTime(6 * 60_000) + await resolveFor('ramp-refused-then-outage') + vi.advanceTimersByTime(31_000) + + await expect(resolveFor('ramp-refused-then-outage')).resolves.toEqual({ + error: expect.objectContaining({ code: 'provider_unavailable' }), + }) + }) + + it('retries a quick transient failure once inside the mint', async () => { + const mint = minter() + mint.mockRejectedValueOnce(unavailable(503)).mockResolvedValueOnce(token) + + await expect(resolveFor('ramp-retry-once')).resolves.toEqual({ + value: { accessToken: token.accessToken }, + }) + expect(mint).toHaveBeenCalledTimes(2) + }) + + it('does not retry an attempt that took as long as a provider timeout', async () => { + const mint = minter() + mint.mockImplementation(async () => { + vi.setSystemTime(Date.now() + 10_000) + throw unavailable() + }) + + await expect(resolveFor('ramp-timed-out')).resolves.toEqual({ + error: expect.objectContaining({ code: 'provider_unavailable' }), + }) + expect(mint).toHaveBeenCalledOnce() + }) + + it('replays a refusal of the credentials for 30 seconds without retrying it', async () => { + const mint = minter() + mint.mockRejectedValue(refused()) + + await resolveFor('ramp-refused') + vi.advanceTimersByTime(29_000) + await expect(resolveFor('ramp-refused')).resolves.toEqual({ + error: expect.objectContaining({ code: 'invalid_credentials' }), + }) + expect(mint).toHaveBeenCalledOnce() + + vi.advanceTimersByTime(2_000) + await resolveFor('ramp-refused') + expect(mint).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['as long as a throttled provider asks', 120_000, 120_000], + ['no longer than five minutes, whatever the provider asks', 3_600_000, 300_000], + ])('waits %s before asking again', async (_case, retryAfterMs, waitMs) => { + const mint = minter() + const credentialId = `ramp-throttled-${retryAfterMs}` + mint.mockRejectedValueOnce(unavailable(429, retryAfterMs)).mockResolvedValueOnce(token) + + await expect(resolveFor(credentialId)).resolves.toEqual({ + error: expect.objectContaining({ code: 'provider_unavailable', status: 429 }), + }) + vi.advanceTimersByTime(waitMs - 1_000) + await expect(resolveFor(credentialId)).resolves.toEqual({ + error: expect.objectContaining({ status: 429 }), + }) + expect(mint).toHaveBeenCalledOnce() + + vi.advanceTimersByTime(2_000) + await expect(resolveFor(credentialId)).resolves.toEqual({ + value: { accessToken: token.accessToken }, + }) + }) + + it('backs off an outage briefly at first, longer while it lasts, and never past 30 seconds', async () => { + const mint = minter() + mint.mockRejectedValue(unavailable()) + + await resolveFor('ramp-outage') + const firstAttempt = mint.mock.calls.length + vi.advanceTimersByTime(6_001) + await resolveFor('ramp-outage') + const secondAttempt = mint.mock.calls.length + expect(secondAttempt).toBeGreaterThan(firstAttempt) + + vi.advanceTimersByTime(6_001) + await resolveFor('ramp-outage') + expect(mint).toHaveBeenCalledTimes(secondAttempt) + + for (let attempt = 0; attempt < 6; attempt++) { + const asked = mint.mock.calls.length + vi.advanceTimersByTime(30_001) + await resolveFor('ramp-outage') + expect(mint.mock.calls.length).toBeGreaterThan(asked) + } + }) + + it('waits at least a minute after a throttled answer that gave no Retry-After', async () => { + const mint = minter() + mint.mockRejectedValueOnce(unavailable(429)).mockResolvedValueOnce(token) + + await resolveFor('ramp-throttled-bare') + vi.advanceTimersByTime(59_000) + await expect(resolveFor('ramp-throttled-bare')).resolves.toEqual({ + error: expect.objectContaining({ status: 429 }), + }) + expect(mint).toHaveBeenCalledOnce() + + vi.advanceTimersByTime(14_000) + await expect(resolveFor('ramp-throttled-bare')).resolves.toEqual({ + value: { accessToken: token.accessToken }, + }) + }) + + it('waits about a minute, not an outage streak longer, for a throttled answer after an outage', async () => { + const mint = minter() + const answers = [unavailable(), unavailable(), unavailable(429)] + mint.mockImplementation(async () => { + throw answers[0] + }) + + await resolveFor('ramp-outage-then-throttled') + answers.shift() + vi.advanceTimersByTime(30_001) + await resolveFor('ramp-outage-then-throttled') + answers.shift() + vi.advanceTimersByTime(30_001) + await expect(resolveFor('ramp-outage-then-throttled')).resolves.toEqual({ + error: expect.objectContaining({ status: 429 }), + }) + mint.mockResolvedValueOnce(token) + + vi.advanceTimersByTime(73_000) + await expect(resolveFor('ramp-outage-then-throttled')).resolves.toEqual({ + value: { accessToken: token.accessToken }, + }) + }) + + it('waits at least 5 seconds before asking again after an outage, whatever the jitter', async () => { + const lowestJitter = vi + .spyOn(globalThis.crypto, 'getRandomValues') + .mockImplementation((array) => array) + try { + const mint = minter() + mint.mockImplementation(async () => { + vi.setSystemTime(Date.now() + 10_000) + throw unavailable() + }) + + await resolveFor('ramp-outage-floor') + vi.advanceTimersByTime(4_900) + await resolveFor('ramp-outage-floor') + + expect(mint).toHaveBeenCalledOnce() + } finally { + lowestJitter.mockRestore() + } + }) + + it('replays a coordinated failure exactly as long as the coordinated layer still replays it', async () => { + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, + providerId: VANTA_SERVICE_ACCOUNT_PROVIDER_ID, + clientId: 'vanta-client', + clientSecret: 'vanta-client-secret', + }), + }) + const mint = minter() + mint + .mockRejectedValueOnce( + new TokenServiceAccountValidationError( + 'provider_unavailable', + 502, + { step: 'mint' }, + { replayForMs: 300 } + ) + ) + .mockResolvedValueOnce(token) + + await resolveFor('vanta-coordinated-replay', VANTA_SERVICE_ACCOUNT_PROVIDER_ID) + vi.advanceTimersByTime(301) + + await expect( + resolveFor('vanta-coordinated-replay', VANTA_SERVICE_ACCOUNT_PROVIDER_ID) + ).resolves.toEqual({ value: { accessToken: token.accessToken } }) + }) + + it('asks the coordinated layer again at once after a failure it does not remember', async () => { + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, + providerId: VANTA_SERVICE_ACCOUNT_PROVIDER_ID, + clientId: 'vanta-client', + clientSecret: 'vanta-client-secret', + }), + }) + const mint = minter() + mint + .mockRejectedValueOnce( + new TokenServiceAccountValidationError('provider_unavailable', 503, { step: 'mint' }) + ) + .mockResolvedValueOnce(token) + + await resolveFor('vanta-coordination-failed', VANTA_SERVICE_ACCOUNT_PROVIDER_ID) + + await expect( + resolveFor('vanta-coordination-failed', VANTA_SERVICE_ACCOUNT_PROVIDER_ID) + ).resolves.toEqual({ value: { accessToken: token.accessToken } }) + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 9700af466a4..572d3ecf8ec 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -19,6 +19,7 @@ const hoisted = vi.hoisted(() => ({ refreshOAuthToken: vi.fn(), decryptQuickBooksOAuthClientConfig: vi.fn(), withLeaderLock: vi.fn(), + mintServiceNowToken: vi.fn(), })) vi.mock('@/lib/concurrency/singleflight', () => ({ @@ -38,6 +39,9 @@ vi.mock('@/lib/credentials/coordinated-client-token', () => ({ return { accessToken: exchanged.accessToken, expiresInSeconds: 86_000 } }, })) +vi.mock('@/lib/credentials/token-service-accounts/grants/servicenow', () => ({ + mintServiceNowClientCredentialsToken: hoisted.mintServiceNowToken, +})) vi.mock('@/lib/credentials/workload-identity/issuer', () => ({ isWorkloadIdentityConfigured: async () => true, mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `sim-oidc|${subject}`, @@ -1438,6 +1442,76 @@ describe('token provider credentials saved in client-credentials mode', () => { ).resolves.toMatchObject({ accessToken: 'lin_oauth_2' }) }) + it('marks a minted ServiceNow token as a bearer token, and a saved login as before', async () => { + hoisted.mintServiceNowToken.mockResolvedValue({ + accessToken: 'sn-minted', + expiresInSeconds: 1799, + }) + storeBlob('servicenow-cc', { + type: 'token_service_account', + providerId: 'servicenow-service-account', + authMethod: 'client_credentials', + clientId: 'sn-client', + clientSecret: 'sn-secret', + domain: 'https://acme.service-now.com', + }) + + await expect( + resolveServiceAccountToken('servicenow-cc', 'servicenow-service-account') + ).resolves.toEqual({ + accessToken: 'sn-minted', + domain: 'https://acme.service-now.com', + authStyle: 'bearer', + }) + + storeBlob('servicenow-login', { + type: 'token_service_account', + providerId: 'servicenow-service-account', + apiToken: 'saved-password', + providerWorkspaceId: 'integration.user', + domain: 'https://acme.service-now.com', + }) + await expect( + resolveServiceAccountToken('servicenow-login', 'servicenow-service-account') + ).resolves.toEqual({ + accessToken: 'saved-password', + domain: 'https://acme.service-now.com', + providerWorkspaceId: 'integration.user', + }) + }) + + it('marks a saved Splunk username and password as basic, and a saved token as before', async () => { + storeBlob('splunk-login', { + type: 'token_service_account', + providerId: 'splunk-service-account', + apiToken: 'saved-password', + providerWorkspaceId: 'svc-sim', + domain: 'https://splunk.example.com:8089', + }) + await expect( + resolveServiceAccountToken('splunk-login', 'splunk-service-account') + ).resolves.toEqual({ + accessToken: 'saved-password', + domain: 'https://splunk.example.com:8089', + providerWorkspaceId: 'svc-sim', + authStyle: 'basic', + }) + + storeBlob('splunk-token', { + type: 'token_service_account', + providerId: 'splunk-service-account', + apiToken: 'splunk-token', + domain: 'https://splunk.example.com:8089', + }) + await expect( + resolveServiceAccountToken('splunk-token', 'splunk-service-account') + ).resolves.toEqual({ + accessToken: 'splunk-token', + domain: 'https://splunk.example.com:8089', + providerWorkspaceId: undefined, + }) + }) + it('still returns a pasted Shopify token as is', async () => { storeBlob( 'shopify-pasted', @@ -1470,6 +1544,49 @@ describe('token provider credentials saved in client-credentials mode', () => { }) }) +describe('token credentials saved before their provider had a region or host to choose', () => { + function storeBlob(blob: Record) { + queueTableRows(credential, [{ encryptedServiceAccountKey: 'enc-legacy' }]) + mocks.decryptSecret.mockResolvedValue({ decrypted: JSON.stringify(blob) }) + } + + beforeEach(() => { + resetDbChainMock() + }) + + it.each([ + ['launchdarkly-service-account', 'us'], + ['langsmith-service-account', 'us'], + ['square-service-account', 'production'], + ['sendgrid-service-account', 'global'], + ['hex-service-account', 'app.hex.tech'], + ['sentry-service-account', 'us'], + ])( + 'resolves a %s token stored without one to the host it always used (%s)', + async (providerId, domain) => { + storeBlob({ type: 'token_service_account', providerId, apiToken: 'legacy-token' }) + + await expect(resolveServiceAccountToken('legacy-1', providerId)).resolves.toMatchObject({ + accessToken: 'legacy-token', + domain, + }) + } + ) + + it('keeps the region a newer credential was saved with', async () => { + storeBlob({ + type: 'token_service_account', + providerId: 'launchdarkly-service-account', + apiToken: 'eu-token', + domain: 'eu', + }) + + await expect( + resolveServiceAccountToken('eu-1', 'launchdarkly-service-account') + ).resolves.toMatchObject({ accessToken: 'eu-token', domain: 'eu' }) + }) +}) + describe('Atlassian service-account resolution', () => { const fetchMock = vi.fn() diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 13f8989a68b..846076c444d 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -20,14 +20,27 @@ import { getClientCredentialAccountMinter, parseClientCredentialAccountSecretBlob, } from '@/lib/credentials/client-credential-accounts/server' -import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity' +import { DATABASE_CONNECTION_DESCRIPTORS } from '@/lib/credentials/database-connections/descriptors' +import { + GOOGLE_SERVICE_ACCOUNT_EXCLUDED_SCOPES, + isGoogleWorkloadIdentitySecret, +} from '@/lib/credentials/google-workload-identity' +import { + assessTokenRequestFailure, + requestTokenWithRetry, + TOKEN_EXPIRY_MARGIN_MS, + TOKEN_FAILURE_STREAK_RETENTION_MS, + type TokenRequestFailureKind, +} from '@/lib/credentials/token-request-resilience' import { getTokenClientCredentialMinter } from '@/lib/credentials/token-service-accounts/client-credentials' import { type CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, + getTokenServiceAccountDomainDefault, isTokenServiceAccountProviderId, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/token-service-accounts/descriptors' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { parseStoredSecretMode, parseTokenServiceAccountSecret, @@ -42,7 +55,10 @@ import { GITHUB_INSTALLATION_PROVIDER_ID, type GitHubInstallationRepositoryScope, } from '@/lib/oauth/github-installation-types' -import { exchangeGoogleServiceAccountJwt } from '@/lib/oauth/google-service-account-transport' +import { + exchangeGoogleServiceAccountJwt, + GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES, +} from '@/lib/oauth/google-service-account-transport' import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram' import { getMicrosoftRefreshTokenExpiry, @@ -219,29 +235,6 @@ export async function resolveOAuthAccountId( return { accountId: credentialId, usedCredentialTable: false } } -/** - * Userinfo scopes are excluded because service accounts don't represent a user - * and cannot request user identity information. Google rejects token requests - * that include these scopes for service account credentials. - */ -const SA_EXCLUDED_SCOPES = new Set([ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', -]) - -/** Google's documented JWT error codes are safe to retain without the provider description. */ -const SA_DIAGNOSTIC_ERROR_CODES = new Set([ - 'access_denied', - 'admin_policy_enforced', - 'deleted_client', - 'disabled_client', - 'invalid_client', - 'invalid_grant', - 'invalid_scope', - 'org_internal', - 'unauthorized_client', -]) - /** * The decrypted key of a live Google service-account credential. Reads running at once (one per * delegated subject) share one query and decrypt; a later read queries again, so it sees a @@ -293,10 +286,11 @@ function readGoogleServiceAccountKey(credentialId: string): Promise !SA_EXCLUDED_SCOPES.has(s)) + const filteredScopes = scopes.filter((s) => !GOOGLE_SERVICE_ACCOUNT_EXCLUDED_SCOPES.has(s)) if (isGoogleWorkloadIdentitySecret(decrypted)) { const { resolveGoogleWorkloadIdentityToken } = await import( @@ -420,7 +414,9 @@ export async function getServiceAccountToken( : { subject: impersonateEmail, scopes: filteredScopes, - ...(errorCode && SA_DIAGNOSTIC_ERROR_CODES.has(errorCode) ? { errorCode } : {}), + ...(errorCode && GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES.has(errorCode) + ? { errorCode } + : {}), }), }) throw new ServiceAccountTokenError(response.status, description, errorCode) @@ -614,11 +610,19 @@ export interface ServiceAccountTokenResult { * Set when the token must be sent in an `x-api-token` header instead of * `Authorization: Bearer` (e.g. Pipedrive personal API tokens), or is a * Sim-signed OIDC token a keyless Snowflake credential presents through - * workload identity federation, or `basic` for an HTTP Basic credential (a - * Zendesk API token sign-in). Absent means Bearer; OAuth credentials never - * carry it. + * workload identity federation, or a JWT a key-pair Snowflake credential + * signs with its private key, or `basic` for an HTTP Basic credential (a + * Zendesk API token sign-in, or a Splunk username and password), or `bearer` for a token minted for a provider + * whose pasted mode is not a bearer token (ServiceNow client credentials). + * Absent means Bearer, except to tools whose pasted mode says otherwise; + * OAuth credentials never carry it. */ - authStyle?: 'x-api-token' | 'snowflake-workload-identity' | 'basic' + authStyle?: + | 'x-api-token' + | 'snowflake-workload-identity' + | 'snowflake-key-pair' + | 'basic' + | 'bearer' } interface CachedClientCredentialToken { @@ -644,7 +648,13 @@ interface MintedServiceAccountToken { interface FailedClientCredentialMint { /** The error the failed mint threw, re-thrown to callers while memoized. */ error: unknown + kind: TokenRequestFailureKind + /** Failures of this kind in a row; the next one of the same kind backs off longer. */ + consecutiveFailures: number secretFingerprint: string + /** Until when the failure is replayed instead of minting again. */ + retryAtMs: number + /** When the entry is dropped: after its replay, once a later failure can no longer extend the streak. */ expiresAtMs: number } @@ -665,10 +675,19 @@ interface FailedClientCredentialMint { * multiple concurrently valid tokens, so each instance minting its own token * is correct. * - * Failed mints are never cached as tokens; instead they are memoized for - * {@link CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS} so a hot workflow on a - * revoked/invalid secret doesn't hammer the provider's token endpoint once - * per block execution. + * A token inside its last {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} is refreshed + * early, and a refresh that fails with an outage or throttling (not a refusal of + * the credentials) keeps the still-valid token in service until + * {@link TOKEN_EXPIRY_MARGIN_MS} before it expires, as MSAL, Azure.Core and + * botocore do with a failed proactive refresh. + * + * Failed mints are never cached as tokens; instead they are replayed (see + * {@link assessTokenRequestFailure}) so a hot workflow neither hammers the + * provider's token endpoint with a revoked secret nor floods a struggling one: + * a refusal for 30 s, a throttled answer as long as its `Retry-After` asks (at + * most five minutes) or for 1 min growing to 5 min without one, and an outage + * for 5 s growing to 30 s while it lasts. A quick outage is first retried once + * inside the mint. * * Both maps are pruned of expired entries on each resolution * ({@link pruneExpiredClientCredentialCaches}), so their size is bounded by the @@ -678,7 +697,6 @@ interface FailedClientCredentialMint { const clientCredentialTokenCache = new Map() const clientCredentialMintFailureCache = new Map() const CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS = 5 * 60 * 1000 -const CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS = 30 * 1000 /** Drops fully-expired token and failure entries so the maps stay bounded. */ function pruneExpiredClientCredentialCaches(nowMs: number): void { @@ -720,15 +738,29 @@ function secretFingerprintOf(encryptedServiceAccountKey: string): string { return encryptedServiceAccountKey.slice(0, 32) } +/** + * Whether a `sharedToken` mint failed with a failure the coordinated layer remembers for every + * worker. Only those are replayed here, for exactly as long as that layer still replays them; a + * failure it does not remember (a lock timeout, a database error) may already be over for + * another worker, so the next resolution asks again. + */ +function isCoordinatedReplay(error: unknown): boolean { + return error instanceof TokenServiceAccountValidationError && error.replayForMs !== undefined +} + /** * Resolves a stored secret that mints short-lived tokens, read-through the * per-instance cache: re-reads the credential row, serves a cached token minted * from the same ciphertext while enough of it remains, replays a recent mint - * failure, and otherwise decrypts and mints. Wrapped in `coalesceLocally` so - * concurrent block executions on one instance share a single mint. - * `sharedToken` providers coordinate one token per application themselves, so - * their tokens are never cached here, and a caller that already read the - * secret to choose its mode passes it as `stored` rather than reading it twice. + * failure, and otherwise decrypts and mints, falling back to the cached token + * while it is still valid when the refresh fails with an outage and dropping it + * when the refresh is refused, so no later outage serves it again. Wrapped in + * `coalesceLocally` so concurrent block executions on one instance share a + * single mint. `sharedToken` providers coordinate one token per application + * themselves (including the in-request retry and the fallback), so their tokens + * are never cached here and only a failure their own layer remembers is + * replayed, for as long as it reports; a caller that already read the secret to choose its mode + * passes it as `stored` rather than reading it twice. */ async function resolveMintedServiceAccountToken( credentialId: string, @@ -761,29 +793,37 @@ async function resolveMintedServiceAccountToken( } const secretFingerprint = secretFingerprintOf(encryptedServiceAccountKey) - const cached = sharedToken ? undefined : clientCredentialTokenCache.get(cacheIdentity) - if ( - cached && - cached.secretFingerprint === secretFingerprint && - cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS - ) { + const cachedEntry = sharedToken ? undefined : clientCredentialTokenCache.get(cacheIdentity) + const cached = cachedEntry?.secretFingerprint === secretFingerprint ? cachedEntry : undefined + if (cached && cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS) { return { ...cached.token } } - - const failed = clientCredentialMintFailureCache.get(cacheIdentity) - if ( - failed && - failed.secretFingerprint === secretFingerprint && - Date.now() < failed.expiresAtMs - ) { + /** + * The cached token while it is still the one in the cache (a provider may refuse it while a + * refresh is in flight) and more than the expiry margin of it is left, checked at each use. + */ + const stillValid = () => + cached && + clientCredentialTokenCache.get(cacheIdentity) === cached && + cached.expiresAtMs - Date.now() > TOKEN_EXPIRY_MARGIN_MS + ? cached + : undefined + + const failedEntry = clientCredentialMintFailureCache.get(cacheIdentity) + const failed = failedEntry?.secretFingerprint === secretFingerprint ? failedEntry : undefined + if (failed && Date.now() < failed.retryAtMs) { + const fallback = failed.kind === 'permanent' ? undefined : stillValid() + if (fallback) return { ...fallback.token } throw failed.error } - clientCredentialMintFailureCache.delete(cacheIdentity) try { const decrypted = stored?.decrypted ?? (await decryptSecret(encryptedServiceAccountKey)).decrypted - const minted = await mint(decrypted) + const minted = sharedToken + ? await mint(decrypted) + : await requestTokenWithRetry(() => mint(decrypted)) + clientCredentialMintFailureCache.delete(cacheIdentity) if (!sharedToken) clientCredentialTokenCache.set(cacheIdentity, { token: minted.token, @@ -793,12 +833,33 @@ async function resolveMintedServiceAccountToken( }) return { ...minted.token } } catch (error) { - if (!(error instanceof Error && error.name === 'AbortError')) { - clientCredentialMintFailureCache.set(cacheIdentity, { - error: options?.privacyMode === 'selector' ? new Error('Credential mint failed') : error, - secretFingerprint, - expiresAtMs: Date.now() + CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS, + if (error instanceof Error && error.name === 'AbortError') throw error + if (sharedToken && !isCoordinatedReplay(error)) { + clientCredentialMintFailureCache.delete(cacheIdentity) + throw error + } + const failure = assessTokenRequestFailure(error, failed) + const retryAtMs = Date.now() + failure.replayMs + clientCredentialMintFailureCache.set(cacheIdentity, { + error: options?.privacyMode === 'selector' ? new Error('Credential mint failed') : error, + kind: failure.kind, + consecutiveFailures: failure.consecutiveFailures, + secretFingerprint, + retryAtMs, + expiresAtMs: retryAtMs + TOKEN_FAILURE_STREAK_RETENTION_MS, + }) + if (failure.kind === 'permanent') { + clientCredentialTokenCache.delete(cacheIdentity) + throw error + } + const fallback = stillValid() + if (fallback) { + logger.warn('Serving a still-valid client-credential token after its refresh failed', { + ...(options?.privacyMode === 'selector' ? {} : { credentialId }), + failure: failure.kind, + retryInMs: failure.replayMs, }) + return { ...fallback.token } } throw error } @@ -879,13 +940,15 @@ function resolveTokenClientCredentialToken( ) } const { clientId, clientSecret, domain } = secret + const authStyle = getTokenServiceAccountDescriptor(secret.providerId)?.clientCredentials + ?.authStyle return resolveMintedServiceAccountToken( credentialId, async () => { // The mint is shared by every coalesced caller, so no one caller's signal may cancel it. const minted = await mint({ clientId, clientSecret, domain }) return { - token: { accessToken: minted.accessToken, domain }, + token: { accessToken: minted.accessToken, domain, ...(authStyle ? { authStyle } : {}) }, expiresInSeconds: minted.expiresInSeconds, } }, @@ -932,6 +995,13 @@ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record ({ accessToken: credentialId, }), + // A saved database connection is read inside the tool operation, bound to the run's owner. + ...Object.fromEntries( + Object.values(DATABASE_CONNECTION_DESCRIPTORS).map(({ providerId }) => [ + providerId, + async (credentialId: string) => ({ accessToken: credentialId }), + ]) + ), [GITHUB_INSTALLATION_PROVIDER_ID]: async (credentialId, { githubRepositoryScope }) => { if (!githubRepositoryScope) throw new Error('GitHub installation tokens require a source repository') @@ -1039,16 +1109,25 @@ export async function resolveServiceAccountToken( credentialId, 'Token service account secret not found' ) - const secret = parseTokenServiceAccountSecret(stored.decrypted, providerId) + const parsed = parseTokenServiceAccountSecret(stored.decrypted, providerId) + // A credential saved before its provider gained a defaulted region or host stores none, and + // resolves to that default: the host it has always used. + const domain = parsed.domain || getTokenServiceAccountDomainDefault(providerId) + const secret = domain === undefined ? parsed : { ...parsed, domain } if ('authMethod' in secret) { return resolveTokenClientCredentialToken(credentialId, secret, stored, options) } - const descriptorAuthStyle = getTokenServiceAccountDescriptor(providerId)?.authStyle + const descriptor = getTokenServiceAccountDescriptor(providerId) + const descriptorAuthStyle = descriptor?.authStyle return { accessToken: secret.apiToken, domain: secret.domain, providerWorkspaceId: secret.providerWorkspaceId, - ...(descriptorAuthStyle === 'x-api-token' ? { authStyle: descriptorAuthStyle } : {}), + ...(descriptor?.passwordWithUsername && secret.providerWorkspaceId + ? { authStyle: 'basic' as const } + : descriptorAuthStyle === 'x-api-token' + ? { authStyle: descriptorAuthStyle } + : {}), } } if (providerId && isClientCredentialAccountProviderId(providerId)) { diff --git a/apps/sim/lib/oauth/google-service-account-transport.ts b/apps/sim/lib/oauth/google-service-account-transport.ts index f3e3b58df0d..85bd48c1849 100644 --- a/apps/sim/lib/oauth/google-service-account-transport.ts +++ b/apps/sim/lib/oauth/google-service-account-transport.ts @@ -6,6 +6,19 @@ import { retryWithExponentialBackoff, } from '@/lib/knowledge/documents/utils' +/** Google's documented JWT-bearer error codes, safe to show or log without the provider description. */ +export const GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES: ReadonlySet = new Set([ + 'access_denied', + 'admin_policy_enforced', + 'deleted_client', + 'disabled_client', + 'invalid_client', + 'invalid_grant', + 'invalid_scope', + 'org_internal', + 'unauthorized_client', +]) + const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]) const TOKEN_EXCHANGE_BUDGET_MS = 30_000 const logger = createLogger('GoogleServiceAccountTransport') diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 6e34d5b29d5..efa2a7160cf 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -12,12 +12,14 @@ import { AhrefsIcon, AirtableIcon, AlgoliaIcon, + AmplitudeIcon, ApifyIcon, ApolloIcon, AsanaIcon, AshbyIcon, AtlassianIcon, AttioIcon, + AzureDataExplorerIcon, AzureIcon, BitbucketIcon, BoxCompanyIcon, @@ -27,6 +29,7 @@ import { BufferIcon, CalComIcon, CalendlyIcon, + CbInsightsIcon, CheckrIcon, CirclebackIcon, ClaudeIcon, @@ -98,6 +101,7 @@ import { JiraIcon, JotformIcon, JupyterIcon, + KalshiIcon, LangsmithIcon, LaunchDarklyIcon, LemlistIcon, @@ -117,9 +121,11 @@ import { MicrosoftOneDriveIcon, MicrosoftPlannerIcon, MicrosoftSharepointIcon, + MicrosoftSqlIcon, MicrosoftTeamsIcon, MicrosoftWordIcon, MondayIcon, + MySQLIcon, NetSuiteIcon, NewRelicIcon, NotionIcon, @@ -132,12 +138,15 @@ import { PipedriveIcon, PlaneIcon, PlanetScaleIcon, + PostgresIcon, PowerBIIcon, ProfoundIcon, PulseIcon, + QdrantIcon, QuartrIcon, QuickBooksIcon, QuiverIcon, + RailwayIcon, RampIcon, RB2BIcon, RedditIcon, @@ -157,6 +166,7 @@ import { SlackIcon, SmartleadIcon, SnowflakeIcon, + SplunkIcon, SportmonksIcon, SpotifyIcon, SquareIcon, @@ -197,6 +207,7 @@ import { DEFAULT_MAX_ERROR_BODY_BYTES, readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' +import { APOLLO_TOKEN_URL } from '@/lib/oauth/apollo' import { getDocusignOAuthUrl } from '@/lib/oauth/docusign' import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' import { @@ -493,6 +504,13 @@ export const OAUTH_PROVIDERS: Record = { 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/adwords', ], + /** + * A JSON-key service account the Google Ads account added as a user signs in as itself + * (https://developers.google.com/google-ads/api/docs/oauth/service-accounts). Keyless + * Google credentials stay refused here: Ads is not in `GOOGLE_KEYLESS_SERVICES`, since + * Google documents no Workload Identity Federation path for it. + */ + serviceAccountProviderId: 'google-service-account', }, 'google-bigquery': { name: 'Google BigQuery', @@ -644,6 +662,17 @@ export const OAUTH_PROVIDERS: Record = { 'offline_access', ], }, + 'azure-data-explorer': { + name: 'Azure Data Explorer', + description: + 'Query and manage Azure Data Explorer clusters as a Microsoft Entra app registration.', + providerId: 'azure-data-explorer', + icon: AzureDataExplorerIcon, + baseProviderIcon: MicrosoftIcon, + serviceAccountProviderId: 'microsoft-app-service-account', + authType: 'service_account', + scopes: [], + }, 'microsoft-dataverse': { name: 'Microsoft Dataverse', description: 'Connect to Microsoft Dataverse and manage records.', @@ -714,6 +743,7 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'microsoft-powerbi', icon: PowerBIIcon, baseProviderIcon: MicrosoftIcon, + serviceAccountProviderId: 'microsoft-app-service-account', scopes: [ 'https://analysis.windows.net/powerbi/api/Workspace.Read.All', 'https://analysis.windows.net/powerbi/api/Report.Read.All', @@ -1345,6 +1375,57 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'oracledb', }, + postgresql: { + name: 'PostgreSQL', + icon: PostgresIcon, + services: { + postgresql: { + name: 'PostgreSQL', + description: 'Query and manage PostgreSQL with a saved connection.', + providerId: 'postgresql', + serviceAccountProviderId: 'postgresql-service-account', + authType: 'service_account', + icon: PostgresIcon, + baseProviderIcon: PostgresIcon, + scopes: [], + }, + }, + defaultService: 'postgresql', + }, + mysql: { + name: 'MySQL', + icon: MySQLIcon, + services: { + mysql: { + name: 'MySQL', + description: 'Query and manage MySQL with a saved connection.', + providerId: 'mysql', + serviceAccountProviderId: 'mysql-service-account', + authType: 'service_account', + icon: MySQLIcon, + baseProviderIcon: MySQLIcon, + scopes: [], + }, + }, + defaultService: 'mysql', + }, + mssql: { + name: 'Microsoft SQL Server', + icon: MicrosoftSqlIcon, + services: { + mssql: { + name: 'Microsoft SQL Server', + description: 'Query and manage Microsoft SQL Server with a saved connection.', + providerId: 'mssql', + serviceAccountProviderId: 'mssql-service-account', + authType: 'service_account', + icon: MicrosoftSqlIcon, + baseProviderIcon: MicrosoftSqlIcon, + scopes: [], + }, + }, + defaultService: 'mssql', + }, oci: { name: 'Oracle Cloud Infrastructure', icon: OracleIcon, @@ -1878,6 +1959,109 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'gauge', }, + amplitude: { + name: 'Amplitude', + icon: AmplitudeIcon, + services: { + amplitude: { + name: 'Amplitude', + description: 'Track events, identify users, and query analytics in Amplitude.', + providerId: 'amplitude', + serviceAccountProviderId: 'amplitude-service-account', + icon: AmplitudeIcon, + baseProviderIcon: AmplitudeIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'amplitude', + }, + kalshi: { + name: 'Kalshi', + icon: KalshiIcon, + services: { + kalshi: { + name: 'Kalshi', + description: 'Read markets and trade on Kalshi with an account API key.', + providerId: 'kalshi', + serviceAccountProviderId: 'kalshi-service-account', + icon: KalshiIcon, + baseProviderIcon: KalshiIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'kalshi', + }, + splunk: { + name: 'Splunk', + icon: SplunkIcon, + services: { + splunk: { + name: 'Splunk', + description: 'Run searches and read saved searches, alerts, indexes, and apps in Splunk.', + providerId: 'splunk', + serviceAccountProviderId: 'splunk-service-account', + icon: SplunkIcon, + baseProviderIcon: SplunkIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'splunk', + }, + qdrant: { + name: 'Qdrant', + icon: QdrantIcon, + services: { + qdrant: { + name: 'Qdrant', + description: 'Upsert, search, and fetch points in a Qdrant vector database.', + providerId: 'qdrant', + serviceAccountProviderId: 'qdrant-service-account', + icon: QdrantIcon, + baseProviderIcon: QdrantIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'qdrant', + }, + railway: { + name: 'Railway', + icon: RailwayIcon, + services: { + railway: { + name: 'Railway', + description: 'Manage Railway projects, services, environments, deployments, and variables.', + providerId: 'railway', + serviceAccountProviderId: 'railway-service-account', + icon: RailwayIcon, + baseProviderIcon: RailwayIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'railway', + }, + cbinsights: { + name: 'CB Insights', + icon: CbInsightsIcon, + services: { + cbinsights: { + name: 'CB Insights', + description: + 'Look up companies, funding, investments, and market intelligence in CB Insights.', + providerId: 'cbinsights', + serviceAccountProviderId: 'cbinsights-service-account', + icon: CbInsightsIcon, + baseProviderIcon: CbInsightsIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'cbinsights', + }, tavily: { name: 'Tavily', icon: TavilyIcon, @@ -1993,8 +2177,34 @@ export const OAUTH_PROVIDERS: Record = { serviceAccountProviderId: 'apollo-service-account', icon: ApolloIcon, baseProviderIcon: ApolloIcon, - scopes: [], - authType: 'service_account', + scopes: [ + 'read_user_profile', + 'mixed_people_api_search', + 'people_match', + 'people_bulk_match', + 'mixed_companies_search', + 'organizations_enrich', + 'organizations_bulk_enrich', + 'contact_write', + 'contact_update', + 'contacts_search', + 'contacts_bulk_create', + 'contacts_bulk_update', + 'account_write', + 'account_update', + 'accounts_search', + 'account_bulk_create', + 'account_stages_update', + 'opportunity_write', + 'opportunities_list', + 'opportunity_read', + 'opportunity_update', + 'emailer_campaigns_search', + 'emailer_campaigns_add_contact_ids', + 'tasks_create', + 'tasks_list', + 'email_accounts_list', + ], }, }, defaultService: 'apollo', @@ -4246,6 +4456,20 @@ function getProviderAuthConfig( supportsRefreshTokenRotation: true, } } + case 'apollo': { + const { clientId, clientSecret } = getConfiguredClientCredentials( + 'apollo', + 'APOLLO_CLIENT_ID', + 'APOLLO_CLIENT_SECRET' + ) + return { + tokenEndpoint: APOLLO_TOKEN_URL, + clientId, + clientSecret, + useBasicAuth: false, + supportsRefreshTokenRotation: true, + } + } case 'pipedrive': { const { clientId, clientSecret } = getConfiguredClientCredentials( 'pipedrive', diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 6353dd66a25..b263d27afc1 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -805,3 +805,44 @@ describe('keyless Google Cloud credential refusals', () => { expect(result.ok ? '' : result.error).toMatch(/use a JSON key credential instead/) }) }) + +describe('Microsoft app credential cloud refusals', () => { + const authenticate = async () => INTERNAL_AUTH + + beforeEach(() => { + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + credentialOwnerUserId: 'owner-1', + }) + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'microsoft-app-gov', + providerId: 'microsoft-app-service-account', + accountId: '', + workspaceId: 'workspace-1', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + id: 'powerbi-tool', + params: {}, + oauth: { required: true, provider: 'microsoft-powerbi' }, + }) + mockResolveServiceAccountToken.mockReset() + mockResolveServiceAccountToken.mockRejectedValue( + new TokenServiceAccountValidationError('cloud_mismatch', 400) + ) + }) + + it('names the cloud mismatch instead of asking to reconnect the credential', async () => { + const result = await resolveCredentialAccessToken({ + requestId: 'microsoft-cloud', + credentialId: 'microsoft-app-gov', + toolId: 'powerbi-tool', + authenticate, + }) + expect(result).toMatchObject({ ok: false, status: 400, code: 'cloud_mismatch' }) + expect(result.ok ? '' : result.error).toMatch(/Microsoft cloud/) + expect(result.ok ? '' : result.error).not.toMatch(/reconnect/) + }) +}) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index bbb8193a6c2..faa5252e909 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -491,7 +491,16 @@ export async function resolveCredentialToken( status: 400, code: error.code, error: - 'This credential cannot be used here. Keyless Google Cloud credentials work only with Google Cloud tools such as BigQuery and cannot impersonate a Google Workspace user; use a JSON key credential instead', + 'This credential cannot be used here. A keyless Google credential works only with Google Cloud tools such as BigQuery and, through domain-wide delegation, with Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar, as its own Workspace user and for the scopes it was delegated. Reconnect it to add one of those services, or use a JSON key credential instead', + } + } + if (error.code === 'cloud_mismatch') { + return { + ok: false, + status: 400, + code: error.code, + error: + 'This credential signs in to a different Microsoft cloud than this service calls. Choose a credential registered in the cloud this service runs in', } } if (error.code === 'grant_not_permitted') { diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 5a79cced08d..7daa861af08 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -42,6 +42,15 @@ export const SLACK_CUSTOM_BOT_SECRET_TYPE = 'slack_custom_bot' as const export const ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID = 'oracledb-service-account' as const +/** Saved PostgreSQL connection (host, port, database, user, password, SSL mode). */ +export const POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID = 'postgresql-service-account' as const + +/** Saved MySQL connection (host, port, database, user, password, SSL mode). */ +export const MYSQL_SERVICE_ACCOUNT_PROVIDER_ID = 'mysql-service-account' as const + +/** Saved Microsoft SQL Server connection (host, port, database, login, password, TLS settings). */ +export const MSSQL_SERVICE_ACCOUNT_PROVIDER_ID = 'mssql-service-account' as const + /** Saved AWS identity (access keys, an assumed role, or a keyless OIDC role) shared by every AWS block. */ export const AWS_SERVICE_ACCOUNT_PROVIDER_ID = 'aws-service-account' as const @@ -55,9 +64,18 @@ export const DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID = 'databricks-service-accoun export const MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID = 'microsoft-app-service-account' as const export type OAuthProvider = + | 'qdrant' + | 'railway' + | 'cbinsights' + | 'splunk' + | 'kalshi' + | 'amplitude' | 'aws' | 'databricks' | 'oracledb' + | 'postgresql' + | 'mysql' + | 'mssql' | 'oracle-epm' | 'oracle-fusion' | 'github-repositories' @@ -96,6 +114,7 @@ export type OAuthProvider = | 'microsoft-intune' | 'microsoft-planner' | 'microsoft-powerbi' + | 'azure-data-explorer' | 'microsoft-teams' | 'microsoft-word' | 'outlook' @@ -238,11 +257,20 @@ export type OAuthProvider = | 'zoho-desk' export type OAuthService = + | 'qdrant' + | 'railway' + | 'cbinsights' + | 'splunk' + | 'kalshi' + | 'amplitude' | 'aws' | 'databricks' | 'jama' | 'flow' | 'oracledb' + | 'postgresql' + | 'mysql' + | 'mssql' | 'oracle-epm' | 'oracle-fusion' | 'oci' @@ -281,6 +309,7 @@ export type OAuthService = | 'microsoft-teams' | 'microsoft-planner' | 'microsoft-powerbi' + | 'azure-data-explorer' | 'microsoft-word' | 'sharepoint' | 'outlook' diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index b80825aa074..d7881738c2a 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -12,6 +12,32 @@ import type { * Used by the OAuth Required Modal and available for any UI that needs to display scope info. */ export const SCOPE_DESCRIPTIONS: Record = { + read_user_profile: 'View your Apollo profile', + mixed_people_api_search: 'Search Apollo people', + people_match: 'Enrich a person in Apollo', + people_bulk_match: 'Enrich people in bulk in Apollo', + mixed_companies_search: 'Search Apollo companies', + organizations_enrich: 'Enrich an Apollo organization', + organizations_bulk_enrich: 'Enrich organizations in bulk in Apollo', + contact_write: 'Create Apollo contacts', + contact_update: 'Update Apollo contacts', + contacts_search: 'Search Apollo contacts', + contacts_bulk_create: 'Create Apollo contacts in bulk', + contacts_bulk_update: 'Update Apollo contacts in bulk', + account_write: 'Create Apollo accounts', + account_update: 'Update Apollo accounts', + accounts_search: 'Search Apollo accounts', + account_bulk_create: 'Create Apollo accounts in bulk', + account_stages_update: 'Update Apollo account stages', + opportunity_write: 'Create Apollo deals', + opportunities_list: 'List Apollo deals', + opportunity_read: 'View Apollo deals', + opportunity_update: 'Update Apollo deals', + emailer_campaigns_search: 'Search Apollo sequences', + emailer_campaigns_add_contact_ids: 'Add contacts to Apollo sequences', + tasks_create: 'Create Apollo tasks', + tasks_list: 'Search Apollo tasks', + email_accounts_list: 'View Apollo email accounts', 'harvest:application_stages:list': 'View Greenhouse application stages', 'harvest:applications:convert_to_candidate': 'Convert prospects to candidates for Greenhouse applications', @@ -918,9 +944,17 @@ export function getMissingRequiredScopes( * `drive.readonly`, and a read-only grant never satisfies a write scope. */ export function isScopeSatisfiedBy(required: string, granted: ReadonlySet): boolean { + const readWrite = readWriteScopeOf(required) + return readWrite !== undefined && granted.has(readWrite) +} + +/** + * The read-write scope that subsumes a `.readonly` scope (`drive` for + * `drive.readonly`), or undefined for a scope that is not read-only. + */ +export function readWriteScopeOf(scope: string): string | undefined { const readonlySuffix = '.readonly' - if (!required.endsWith(readonlySuffix)) return false - return granted.has(required.slice(0, -readonlySuffix.length)) + return scope.endsWith(readonlySuffix) ? scope.slice(0, -readonlySuffix.length) : undefined } /** diff --git a/apps/sim/lib/permission-groups/block-successors.generated.ts b/apps/sim/lib/permission-groups/block-successors.generated.ts index 5fdfd4f0989..dd25ee39788 100644 --- a/apps/sim/lib/permission-groups/block-successors.generated.ts +++ b/apps/sim/lib/permission-groups/block-successors.generated.ts @@ -13,12 +13,14 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { agentphone: 'agentphone_v2', ahrefs: 'ahrefs_v2', algolia: 'algolia_v2', + amplitude: 'amplitude_v2', api_trigger: 'start_trigger', apify: 'apify_v2', apollo: 'apollo_v2', appconfig: 'appconfig_v2', ashby: 'ashby_v2', athena: 'athena_v2', + azure_data_explorer: 'azure_data_explorer_v2', azure_devops: 'azure_devops_v2', box: 'box_v2', brex: 'brex_v2', @@ -26,6 +28,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { browser_use: 'browser_use_v2', buffer: 'buffer_v2', calendly: 'calendly_v2', + cbinsights: 'cbinsights_v2', chat_trigger: 'start_trigger', checkr: 'checkr_v2', circleback: 'circleback_v2', @@ -94,7 +97,8 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { jotform: 'jotform_v2', jupyter: 'jupyter_v3', jupyter_v2: 'jupyter_v3', - kalshi: 'kalshi_v2', + kalshi: 'kalshi_v3', + kalshi_v2: 'kalshi_v3', lambda: 'lambda_v2', langsmith: 'langsmith_v2', launchdarkly: 'launchdarkly_v2', @@ -113,6 +117,8 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { microsoft_excel: 'microsoft_excel_v2', mistral_parse: 'mistral_parse_v3', mistral_parse_v2: 'mistral_parse_v3', + mssql: 'mssql_v2', + mysql: 'mysql_v2', new_relic: 'new_relic_v2', notion: 'notion_v2', okta: 'okta_v2', @@ -122,12 +128,15 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { persona: 'persona_v2', plane: 'plane_v2', planetscale: 'planetscale_v2', + postgresql: 'postgresql_v2', profound: 'profound_v2', pulse: 'pulse_v3', pulse_v2: 'pulse_v3', + qdrant: 'qdrant_v2', quartr: 'quartr_v2', quiver: 'quiver_v3', quiver_v2: 'quiver_v3', + railway: 'railway_v2', rb2b: 'rb2b_v2', rds: 'rds_v2', reducto: 'reducto_v3', @@ -151,6 +160,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { sixtyfour: 'sixtyfour_v2', slack: 'slack_v2', smartlead: 'smartlead_v2', + splunk: 'splunk_v2', sportmonks: 'sportmonks_v2', sqs: 'sqs_v2', square: 'square_v2', diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index ab3743d91a2..d5c921c8faf 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -59,6 +59,7 @@ export const CAPABILITY_IDS = [ 'sandboxes.use', 'knowledge.export', 'project_issues.use', + 'computer_use.use', ] as const export type PermissionGroupCapability = (typeof CAPABILITY_IDS)[number] @@ -440,6 +441,14 @@ export const CAPABILITY_RULES = { deniedBy: (config: PermissionGroupConfig, projectId: string) => config.deniedPartialAccessProjectIssues.includes(projectId), }, + /** Subsumes `copilot.use`: only Chat issues computer actions. */ + 'computer_use.use': { + kind: 'static', + configKeys: ['disableComputerUse', 'hideCopilot'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Computer use', + deniedBy: (config) => config.disableComputerUse || config.hideCopilot, + }, } satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule } /** diff --git a/apps/sim/lib/permission-groups/fields.test.ts b/apps/sim/lib/permission-groups/fields.test.ts index 40248a40220..bc45e361939 100644 --- a/apps/sim/lib/permission-groups/fields.test.ts +++ b/apps/sim/lib/permission-groups/fields.test.ts @@ -165,6 +165,7 @@ const fixtures: readonly CoercionFixture[] = [ disableKnowledgeBaseExport: true, deniedPartialAccessProjectIssues: ['project-a'], deniedAccessRequests: ['feature:hideTablesTab'], + disableComputerUse: true, }, expected: { allowedIntegrations: ['slack_v2'], @@ -211,6 +212,7 @@ const fixtures: readonly CoercionFixture[] = [ disableKnowledgeBaseExport: true, deniedPartialAccessProjectIssues: ['project-a'], deniedAccessRequests: ['feature:hideTablesTab'], + disableComputerUse: true, defaultAgentModel: null, }, }, diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index c3ae2521abf..fd157d7e2bb 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -533,6 +533,13 @@ export const PERMISSION_GROUP_FIELDS = { 'request', 'Access requests are unavailable for the listed canonical target keys when access is restricted.' ), + disableComputerUse: booleanRestriction('capability', { + scope: 'workspace-or-organization', + id: 'disable-computer-use', + label: 'Computer Use', + category: 'Tools', + hint: "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it.", + }), } satisfies Record export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS diff --git a/apps/sim/lib/sim-search/live/service-session.ts b/apps/sim/lib/sim-search/live/service-session.ts index e39c54c9ad3..86d335fdbaa 100644 --- a/apps/sim/lib/sim-search/live/service-session.ts +++ b/apps/sim/lib/sim-search/live/service-session.ts @@ -81,6 +81,7 @@ export async function createLiveServiceSession(input: { connector: source, userId: credentialUserId, requestId: 'live-source-search', + scope: { kind: 'organization', organizationId: source.organizationId }, sourceConfig: source.config, signal, }) diff --git a/apps/sim/lib/webhooks/polling/gmail.ts b/apps/sim/lib/webhooks/polling/gmail.ts index 7f8b75b7c88..58b40ba8d4b 100644 --- a/apps/sim/lib/webhooks/polling/gmail.ts +++ b/apps/sim/lib/webhooks/polling/gmail.ts @@ -49,6 +49,12 @@ interface SimplifiedEmail { from: string to: string cc: string + bcc: string | null + replyTo: string | null + rfcMessageId: string | null + inReplyTo: string | null + references: string | null + snippet: string | null date: string | null bodyText: string bodyHtml: string @@ -509,6 +515,12 @@ async function processEmails( from: headers.from || '', to: headers.to || '', cc: headers.cc || '', + bcc: headers.bcc ?? null, + replyTo: headers['reply-to'] ?? null, + rfcMessageId: headers['message-id'] ?? null, + inReplyTo: headers['in-reply-to'] ?? null, + references: headers.references ?? null, + snippet: email.snippet ?? null, date, bodyText: textContent, bodyHtml: htmlContent, diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index 72723f26cb4..4a4a75d05f3 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -69,6 +69,12 @@ interface CalendarEvent { creator?: CalendarEventPerson organizer?: CalendarEventPerson recurringEventId?: string + originalStartTime?: CalendarEventTime + hangoutLink?: string + conferenceData?: Record + visibility?: string + transparency?: string + iCalUID?: string } interface SimplifiedCalendarEvent { @@ -86,6 +92,13 @@ interface SimplifiedCalendarEvent { attendees: CalendarEventAttendee[] | null creator: CalendarEventPerson | null organizer: CalendarEventPerson | null + hangoutLink: string | null + conferenceData: Record | null + recurringEventId: string | null + originalStartTime: CalendarEventTime | null + visibility: string | null + transparency: string | null + iCalUID: string | null } interface GoogleCalendarWebhookPayload { @@ -280,6 +293,13 @@ function simplifyEvent( attendees: event.attendees ?? null, creator: event.creator ?? null, organizer: event.organizer ?? null, + hangoutLink: event.hangoutLink ?? null, + conferenceData: event.conferenceData ?? null, + recurringEventId: event.recurringEventId ?? null, + originalStartTime: event.originalStartTime ?? null, + visibility: event.visibility ?? null, + transparency: event.transparency ?? null, + iCalUID: event.iCalUID ?? null, } } diff --git a/apps/sim/lib/webhooks/polling/outlook.ts b/apps/sim/lib/webhooks/polling/outlook.ts index cb350ef9d1a..8655ecc53a9 100644 --- a/apps/sim/lib/webhooks/polling/outlook.ts +++ b/apps/sim/lib/webhooks/polling/outlook.ts @@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { htmlToText } from 'html-to-text' import { pollingIdempotency } from '@/lib/core/idempotency/service' import { fetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' +import { AttachmentDownloadBudget } from '@/lib/uploads/utils/attachment-download-budget' import { getProviderConfig, type PollingProviderHandler, @@ -19,6 +20,8 @@ import { updateWebhookProviderConfig, } from '@/lib/webhooks/polling/utils' import { processPolledWebhookEvent } from '@/lib/webhooks/processor' +import { downloadAttachments } from '@/tools/outlook/read' +import type { OutlookAttachment } from '@/tools/outlook/types' interface OutlookWebhookConfig { credentialId: string @@ -58,18 +61,25 @@ interface OutlookEmail { address: string } }> + bccRecipients?: OutlookEmailRecipient[] + replyTo?: OutlookEmailRecipient[] receivedDateTime: string sentDateTime: string hasAttachments: boolean isRead: boolean parentFolderId: string + internetMessageId?: string + importance?: string + categories?: string[] + flag?: { flagStatus?: string } + webLink?: string } -interface OutlookAttachment { - name: string - data: Buffer - contentType: string - size: number +interface OutlookEmailRecipient { + emailAddress?: { + name?: string + address?: string + } } interface SimplifiedOutlookEmail { @@ -88,6 +98,23 @@ interface SimplifiedOutlookEmail { folderId: string messageId: string threadId: string + bcc: string + replyTo: string + internetMessageId: string | null + importance: string | null + categories: string[] + flagStatus: string | null + bodyPreview: string + webLink: string | null +} + +function joinAddresses(recipients: OutlookEmailRecipient[] | undefined): string { + return ( + recipients + ?.map((recipient) => recipient.emailAddress?.address) + .filter((address): address is string => Boolean(address)) + .join(', ') ?? '' + ) } interface OutlookWebhookPayload { @@ -195,7 +222,7 @@ async function fetchNewOutlookEmails( params.append( '$select', - 'id,conversationId,subject,bodyPreview,body,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,hasAttachments,isRead,parentFolderId' + 'id,conversationId,subject,bodyPreview,body,from,toRecipients,ccRecipients,bccRecipients,replyTo,receivedDateTime,sentDateTime,hasAttachments,isRead,parentFolderId,internetMessageId,importance,categories,flag,webLink' ) params.append('$orderby', 'receivedDateTime desc') const maxEmails = Math.min(config.maxEmailsPerPoll || 25, OUTLOOK_HARD_MAX_EMAILS) @@ -388,7 +415,28 @@ async function processOutlookEmails( let attachments: OutlookAttachment[] = [] if (config.includeAttachments && email.hasAttachments) { try { - attachments = await downloadOutlookAttachments(accessToken, email.id, logger) + attachments = await downloadAttachments( + email.id, + accessToken, + new AttachmentDownloadBudget(), + { + fetchImpl: (url, init) => fetchWithRetry(url, init), + keepPartialOnLimit: true, + preferMetadataSize: true, + onSkip: (attachmentName, declaredSize) => + logger.warn('Skipping Outlook attachment over the per-email byte budget', { + emailId: email.id, + attachmentName, + declaredSize, + }), + onFailure: (stage, status) => + logger.warn('Outlook attachment request failed', { + emailId: email.id, + stage, + status, + }), + } + ) } catch (error) { logger.error(`Error downloading attachments for email ${email.id}:`, error) } @@ -416,6 +464,14 @@ async function processOutlookEmails( folderId: email.parentFolderId, messageId: email.id, threadId: email.conversationId, + bcc: joinAddresses(email.bccRecipients), + replyTo: joinAddresses(email.replyTo), + internetMessageId: email.internetMessageId ?? null, + importance: email.importance ?? null, + categories: email.categories ?? [], + flagStatus: email.flag?.flagStatus ?? null, + bodyPreview: email.bodyPreview ?? '', + webLink: email.webLink ?? null, } const payload: OutlookWebhookPayload = { @@ -468,65 +524,10 @@ async function processOutlookEmails( return { processedCount, failedCount } } -async function downloadOutlookAttachments( - accessToken: string, - messageId: string, - logger: Logger -): Promise { - const attachments: OutlookAttachment[] = [] - - try { - const response = await fetchWithRetry( - `https://graph.microsoft.com/v1.0/me/messages/${messageId}/attachments`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) - - if (!response.ok) { - logger.error(`Failed to fetch attachments for message ${messageId}`) - return attachments - } - - const data = await response.json() - const attachmentsList = data.value || [] - - for (const attachment of attachmentsList) { - try { - if (attachment['@odata.type'] === '#microsoft.graph.fileAttachment') { - const contentBytes = attachment.contentBytes - if (contentBytes) { - const buffer = Buffer.from(contentBytes, 'base64') - attachments.push({ - name: attachment.name, - data: buffer, - contentType: attachment.contentType, - size: attachment.size, - }) - } - } - } catch (error) { - logger.error( - `Error processing attachment ${attachment.id} for message ${messageId}:`, - error - ) - } - } - - logger.info(`Downloaded ${attachments.length} attachments for message ${messageId}`) - } catch (error) { - logger.error(`Error downloading attachments for message ${messageId}:`, error) - } - - return attachments -} - async function markOutlookEmailAsRead(accessToken: string, messageId: string, logger: Logger) { try { const response = await fetchWithRetry( - `https://graph.microsoft.com/v1.0/me/messages/${messageId}`, + `https://graph.microsoft.com/v1.0/me/messages/${encodeURIComponent(messageId)}`, { method: 'PATCH', headers: { diff --git a/apps/sim/lib/webhooks/providers/bitbucket.ts b/apps/sim/lib/webhooks/providers/bitbucket.ts index e0fa832499c..ae9f8077155 100644 --- a/apps/sim/lib/webhooks/providers/bitbucket.ts +++ b/apps/sim/lib/webhooks/providers/bitbucket.ts @@ -260,16 +260,19 @@ async function deleteProvenBitbucketCandidate( } ) } catch (error) { - logger.warn(`Failed to delete proven Bitbucket candidate ${externalId}`, { + logger.warn('Failed to delete proven Bitbucket candidate', { requestId, + externalId, error: getErrorMessage(error), }) return false } if (!response.ok && response.status !== 404) { - logger.warn(`Failed to delete proven Bitbucket candidate ${externalId} (${response.status})`, { + logger.warn('Failed to delete proven Bitbucket candidate', { requestId, + externalId, + status: response.status, }) return false } @@ -454,10 +457,11 @@ export const bitbucketHandler: WebhookProviderHandler = { const { isBitbucketEventMatch } = await import('@/triggers/bitbucket/utils') if (!triggerId || !isBitbucketEventMatch(triggerId, eventKey)) { - logger.debug( - `Bitbucket event '${eventKey}' does not match trigger '${triggerId || 'unknown'}', skipping`, - { requestId } - ) + logger.debug('Bitbucket event does not match trigger, skipping', { + requestId, + eventKey, + triggerId: triggerId || 'unknown', + }) return false } return true @@ -520,8 +524,9 @@ export const bitbucketHandler: WebhookProviderHandler = { existingCandidate.events.length === 1 && existingCandidate.events[0] === eventKey if (candidateMatchesCheckpoint && checkpointedSecret) { - logger.info(`Reusing checkpointed Bitbucket candidate ${existingCandidate.externalId}`, { + logger.info('Reusing checkpointed Bitbucket candidate', { requestId: ctx.requestId, + externalId: existingCandidate.externalId, }) return { providerConfigUpdates: { @@ -577,8 +582,9 @@ export const bitbucketHandler: WebhookProviderHandler = { if (!response.ok) { const detail = await readBitbucketErrorDetail(response) - logger.error(`Failed to create Bitbucket webhook (${response.status})`, { + logger.error('Failed to create Bitbucket webhook', { requestId: ctx.requestId, + status: response.status, detail, workspaceSlug, repoSlug, @@ -614,8 +620,11 @@ export const bitbucketHandler: WebhookProviderHandler = { throw new Error('Bitbucket webhook was created but no hook UUID was returned.') } - logger.info(`Created Bitbucket webhook ${externalId} for ${workspaceSlug}/${repoSlug}`, { + logger.info('Created Bitbucket webhook', { requestId: ctx.requestId, + externalId, + workspaceSlug, + repoSlug, }) return { providerConfigUpdates: { externalId, webhookSecret, eventTypes: [eventKey] }, @@ -655,7 +664,7 @@ export const bitbucketHandler: WebhookProviderHandler = { throw createBitbucketApiError(response.status, 'delete', detail) } - logger.info(`Deleted Bitbucket webhook ${externalId}`, { requestId: ctx.requestId }) + logger.info('Deleted Bitbucket webhook', { requestId: ctx.requestId, externalId }) } catch (error) { logger.warn('Error deleting Bitbucket webhook (non-fatal)', error, { requestId: ctx.requestId, diff --git a/apps/sim/lib/webhooks/providers/gmail.test.ts b/apps/sim/lib/webhooks/providers/gmail.test.ts index 8054ec82120..2a44b595161 100644 --- a/apps/sim/lib/webhooks/providers/gmail.test.ts +++ b/apps/sim/lib/webhooks/providers/gmail.test.ts @@ -9,6 +9,12 @@ const sampleEmail = { from: 'sender@example.com', to: 'recipient@example.com', cc: '', + bcc: null, + replyTo: null, + rfcMessageId: null, + inReplyTo: null, + references: null, + snippet: null, date: '2026-07-08T00:00:00.000Z', bodyText: 'plain text body', bodyHtml: '

    html body

    ', diff --git a/apps/sim/lib/webhooks/providers/google-forms.ts b/apps/sim/lib/webhooks/providers/google-forms.ts index decbfa7222c..fdb70b6c498 100644 --- a/apps/sim/lib/webhooks/providers/google-forms.ts +++ b/apps/sim/lib/webhooks/providers/google-forms.ts @@ -43,6 +43,8 @@ export const googleFormsHandler: WebhookProviderHandler = { createTime, lastSubmittedTime, formId, + respondentEmail: + typeof b?.respondentEmail === 'string' && b.respondentEmail ? b.respondentEmail : null, answers: normalizeAnswers(b?.answers), ...(includeRaw ? { raw: b?.raw ?? b } : {}), }, diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index 71858790543..7bc6ab3a3b7 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -14,7 +14,13 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { + isPayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/lib/oauth/credential-service' +import { AttachmentDownloadBudget } from '@/lib/uploads/utils/attachment-download-budget' import { getCredentialAccessToken, getNotificationUrl, @@ -31,9 +37,36 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { extractMessageAttachments, extractMessageDetails } from '@/tools/microsoft_teams/utils' const logger = createLogger('WebhookProvider:MicrosoftTeams') +const MAX_TEAMS_MESSAGE_BYTES = 5 * 1024 * 1024 + +/** Read one attachment body against the per-notification byte budget. */ +async function readTeamsAttachment( + response: Parameters[0], + budget: AttachmentDownloadBudget, + attachmentName: string +): Promise { + try { + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: budget.remainingBytes, + label: 'Teams attachment', + }) + return budget.consume(buffer, 'Teams attachment') + } catch (error) { + if (isPayloadSizeLimitError(error)) { + logger.warn('Skipping Teams attachment over the per-notification byte budget', { + attachmentName, + observedBytes: error.observedBytes, + remainingBytes: budget.remainingBytes, + }) + } + throw error + } +} + function validateMicrosoftTeamsSignature( hmacSecret: string, signature: string, @@ -201,6 +234,7 @@ async function formatTeamsGraphNotification( const includeAttachments = providerConfig.includeAttachments !== false let message: Record | null = null + const attachmentBudget = new AttachmentDownloadBudget() const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = [] let accessToken: string | null = null @@ -240,7 +274,10 @@ async function formatTeamsGraphNotification( const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) if (res.ok) { - message = (await res.json()) as Record + message = await readResponseJsonWithLimit>(res, { + maxBytes: MAX_TEAMS_MESSAGE_BYTES, + label: 'Teams message', + }) if (includeAttachments && (message?.attachments as unknown[] | undefined)?.length) { const attachments = Array.isArray(message?.attachments) @@ -282,8 +319,11 @@ async function formatTeamsGraphNotification( ) if (directRes?.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) + buffer = await readTeamsAttachment( + directRes, + attachmentBudget, + attachmentName + ) mimeType = directRes.headers.get('content-type') || contentTypeHint || @@ -302,8 +342,11 @@ async function formatTeamsGraphNotification( }) if (graphRes.ok) { - const arrayBuffer = await graphRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) + buffer = await readTeamsAttachment( + graphRes, + attachmentBudget, + attachmentName + ) mimeType = graphRes.headers.get('content-type') || contentTypeHint || @@ -358,8 +401,11 @@ async function formatTeamsGraphNotification( }) if (directRes.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) + buffer = await readTeamsAttachment( + directRes, + attachmentBudget, + attachmentName + ) mimeType = directRes.headers.get('content-type') || contentTypeHint || @@ -368,7 +414,10 @@ async function formatTeamsGraphNotification( continue } } else { - const metadata = (await metadataRes.json()) as Record + const metadata = await readResponseJsonWithLimit>( + metadataRes, + { maxBytes: MAX_TEAMS_MESSAGE_BYTES, label: 'Teams attachment metadata' } + ) const downloadUrl = metadata['@microsoft.graph.downloadUrl'] as | string | undefined @@ -381,8 +430,11 @@ async function formatTeamsGraphNotification( ) if (downloadRes?.ok) { - const arrayBuffer = await downloadRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) + buffer = await readTeamsAttachment( + downloadRes, + attachmentBudget, + attachmentName + ) const fileInfo = metadata.file as Record | undefined mimeType = downloadRes.headers.get('content-type') || @@ -411,8 +463,7 @@ async function formatTeamsGraphNotification( 'teams-attachment-generic' ) if (ares?.ok) { - const arrayBuffer = await ares.arrayBuffer() - buffer = Buffer.from(arrayBuffer) + buffer = await readTeamsAttachment(ares, attachmentBudget, attachmentName) mimeType = ares.headers.get('content-type') || contentTypeHint || @@ -449,6 +500,8 @@ async function formatTeamsGraphNotification( } } + const changeType = typeof notification.changeType === 'string' ? notification.changeType : null + if (!message) { logger.warn('No message data available for Teams notification', { chatId: resolvedChatId, @@ -462,6 +515,21 @@ async function formatTeamsGraphNotification( text: '', created_at: '', attachments: [], + change_type: changeType, + from_id: null, + from_type: null, + from_identity: null, + message_type: null, + content_type: null, + subject: null, + importance: null, + web_url: null, + last_modified_at: null, + last_edited_at: null, + deleted_at: null, + mentions: [], + reactions: [], + attachment_metadata: [], } } @@ -469,6 +537,8 @@ async function formatTeamsGraphNotification( const from = ((message.from as Record)?.user as Record) || {} const createdAt = (message.createdDateTime as string) || '' + const details = extractMessageDetails(message) + return { message_id: resolvedMessageId, chat_id: resolvedChatId, @@ -476,6 +546,21 @@ async function formatTeamsGraphNotification( text: messageText, created_at: createdAt, attachments: rawAttachments, + change_type: changeType, + from_id: details.senderId, + from_type: details.senderType, + from_identity: details.from, + message_type: typeof message.messageType === 'string' ? message.messageType : null, + content_type: details.contentType, + subject: details.subject, + importance: details.importance, + web_url: details.webUrl, + last_modified_at: details.lastModifiedDateTime, + last_edited_at: details.lastEditedDateTime, + deleted_at: details.deletedDateTime, + mentions: details.mentions, + reactions: details.reactions, + attachment_metadata: extractMessageAttachments(message), } } diff --git a/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts b/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts index 9d1a2dd89fe..cd250609e64 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts @@ -3,7 +3,8 @@ * PostgreSQL: the pause's reviewer content is stored and read back with the run's resolved * secret redacted, and the run detail withholds that content on the same terms as the run's * output, while the pause's input fields stay readable. The workspace's paused-run list shows - * each point on exactly the same terms. + * each point on exactly the same terms. A run whose resumed execution failed reads failed, not + * paused, once no point is left to resume. */ import { db } from '@sim/db' import { @@ -32,6 +33,7 @@ import { encryptSecret } from '@/lib/core/security/encryption' import { executeWorkflowOperation } from '@/lib/workflows/application/execute-workflow' import { listPausedRuns } from '@/lib/workflows/application/list-paused-runs' import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run' +import { listWorkflowExecutions } from '@/lib/workflows/executor/execution-queries' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import type { PausePoint } from '@/executor/types' @@ -475,3 +477,55 @@ describe("the workspace's paused-run list", () => { expect(second.runs[0].pausedAt.getTime()).toBeLessThanOrEqual(first.runs[0].pausedAt.getTime()) }) }) + +describe('a run whose resumed execution failed', () => { + /** What a failed resume leaves behind: the log failed, its point failed, the record still open. */ + async function settleAsFailed(runId: string) { + const [row] = await db + .select({ id: pausedExecutions.id, pausePoints: pausedExecutions.pausePoints }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, runId)) + const points = row.pausePoints as Record + await db + .update(pausedExecutions) + .set({ + pausePoints: Object.fromEntries( + Object.entries(points).map(([id, point]) => [id, { ...point, resumeStatus: 'failed' }]) + ), + }) + .where(eq(pausedExecutions.id, row.id)) + await db + .update(workflowExecutionLogs) + .set({ status: 'failed', level: 'error', endedAt: new Date() }) + .where(eq(workflowExecutionLogs.executionId, runId)) + } + + async function listedStatus(runId: string) { + const rows = await listWorkflowExecutions({ workflowId, limit: 100, order: 'desc' }) + return rows.data.find((row) => row.executionId === runId)?.status + } + + it('reads failed in the run detail and the run list once no point can be resumed', async () => { + const runId = await pausedRun() + await settleAsFailed(runId) + + const run = await readRun(owner, runId, { includeOutput: false }) + expect(run).toMatchObject({ status: 'failed', paused: null, pausePoints: null }) + expect(await listedStatus(runId)).toBe('failed') + }) + + it('still reads paused while a point is waiting, even though the log has finished', async () => { + const runId = await pausedRun() + await db + .update(workflowExecutionLogs) + .set({ status: 'failed', level: 'error', endedAt: new Date() }) + .where(eq(workflowExecutionLogs.executionId, runId)) + + const run = await readRun(owner, runId, { includeOutput: false }) + expect(run.status).toBe('paused') + expect(run.pausePoints).toEqual([ + expect.objectContaining({ contextId: 'approval', status: 'paused' }), + ]) + expect(await listedStatus(runId)).toBe('paused') + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-queries.ts b/apps/sim/lib/workflows/executor/execution-queries.ts index 0835acd023f..03bef36954a 100644 --- a/apps/sim/lib/workflows/executor/execution-queries.ts +++ b/apps/sim/lib/workflows/executor/execution-queries.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' import { and, asc, desc, eq, gt, gte, lt, lte, or, sql } from 'drizzle-orm' +import { PAUSE_POINT_ROWS } from '@/lib/workflows/executor/pause-point-sql' export type WorkflowExecutionStatus = | 'pending' @@ -26,8 +27,20 @@ export interface ListWorkflowExecutionsInput { order: 'asc' | 'desc' } +/** + * An open pause record reads `paused` unless the log has finished and no point is left to resume, + * the same rule `readWorkflowExecutionStatus` applies to a single run. + */ const executionStatus = sql`CASE - WHEN ${pausedExecutions.status} IN ('paused', 'partially_resumed') THEN 'paused' + WHEN ${pausedExecutions.status} IN ('paused', 'partially_resumed') + AND NOT ( + ${workflowExecutionLogs.status} IN ('completed', 'failed', 'cancelled') + AND NOT EXISTS ( + select 1 from ${PAUSE_POINT_ROWS} + where coalesce(point.value ->> 'resumeStatus', 'paused') NOT IN ('resumed', 'failed') + ) + ) + THEN 'paused' ELSE ${workflowExecutionLogs.status} END` diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index fa3e92c1770..6fe2d592dc0 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({ import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs' import { isWaitingHumanPausePoint } from '@/lib/workflows/executor/execution-state' import { + getProjectedWorkflowExecutionStatus, getWorkflowExecutionStatus, readPausedRunPoints, } from '@/lib/workflows/executor/execution-status' @@ -665,6 +666,120 @@ describe('getWorkflowExecutionStatus polls that read no execution data', () => { }) }) +describe('getWorkflowExecutionStatus once a pause has settled', () => { + const FAILED_TIME_POINT = { + contextId: 'wait-five', + blockId: 'wait-five', + registeredAt: '2026-08-05T12:00:01.000Z', + resumeStatus: 'failed', + pauseKind: 'time', + resumeAt: '2026-08-05T12:05:01.000Z', + } + + /** A run whose resumed execution failed: the log is terminal, the pause record still open. */ + function queueFailedRunWithOpenPause(pausePoints: Record) { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'failed', + level: 'error', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: new Date('2026-08-05T12:05:03.000Z'), + totalDurationMs: 303000, + executionData: {}, + costTotal: null, + }, + ]) + queueTableRows(schemaMock.resumeQueue, []) + queueTableRows(schemaMock.pausedExecutions, [ + { + id: 'paused-execution-1', + status: 'paused', + pausePoints, + metadata: {}, + resumedCount: 0, + pausedAt: new Date('2026-08-05T12:00:01.000Z'), + nextResumeAt: null, + }, + ]) + } + + beforeEach(() => { + resetDbChainMock() + mockGetJob.mockResolvedValue(null) + mockMaterializeForDisplayWithBlockOutputs.mockResolvedValue({ + executionData: { + error: 'Lookup: upstream returned 500', + traceSpans: [ + { + id: 'span-1', + name: 'Lookup', + type: 'api', + status: 'error', + blockId: 'lookup', + executionOrder: 2, + }, + ], + }, + blockOutputs: new Map(), + }) + }) + + it('reports a run whose resumed execution failed as failed, with its error and failing block', async () => { + queueFailedRunWithOpenPause({ 'wait-five': FAILED_TIME_POINT }) + + const projected = await getProjectedWorkflowExecutionStatus(input) + + expect(projected?.status).toMatchObject({ + status: 'failed', + paused: null, + error: 'Lookup: upstream returned 500', + }) + expect(projected?.pausePoints).toBeNull() + expect(projected?.failedBlock).toMatchObject({ blockId: 'lookup', blockName: 'Lookup' }) + }) + + it.each([ + [ + 'a human pause', + { + contextId: 'approval', + blockId: 'approval', + resumeStatus: 'paused', + pauseKind: 'human', + resumeAt: null, + }, + ], + [ + 'a time pause', + { + contextId: 'wait-ten', + blockId: 'wait-ten', + resumeStatus: 'paused', + pauseKind: 'time', + resumeAt: '2026-08-05T12:10:01.000Z', + }, + ], + ])('still reads paused while %s is waiting beside the failed one', async (_, waiting) => { + queueFailedRunWithOpenPause({ 'wait-five': FAILED_TIME_POINT, [waiting.contextId]: waiting }) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + status: 'paused', + error: null, + paused: { + contextId: waiting.contextId, + pauseKind: waiting.pauseKind, + resumeAt: waiting.resumeAt, + }, + }) + }) +}) + describe('readPausedRunPoints cap', () => { beforeEach(() => { resetDbChainMock() diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index 16309c385ba..c6387f60d1e 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -83,6 +83,11 @@ function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null { }, null) } +/** A point no resume can act on any more: it resumed, or its resumed execution failed. */ +function isSettledPausePoint(point: PausePoint): boolean { + return point.resumeStatus === 'resumed' || point.resumeStatus === 'failed' +} + function extractError(executionData: unknown): string | null { if (!executionData || typeof executionData !== 'object') return null const data = executionData as ExecutionDataShape @@ -519,8 +524,15 @@ async function readWorkflowExecutionStatus( ) .limit(1) + /** + * An open pause record makes the run read `paused` unless the log has finished and no point + * is left to resume: a resume that ran and failed marks its point `failed` and fails the log + * but leaves the record open, and that point can never be resumed again. + */ const isCurrentlyPaused = - !!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed') + !!pausedRow && + (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed') && + !(hasTerminalLog && normalizePausePoints(pausedRow.pausePoints).every(isSettledPausePoint)) let status: WorkflowExecutionStatusResponse['status'] if (isCurrentlyPaused) { diff --git a/apps/sim/lib/workflows/executor/pause-point-sql.ts b/apps/sim/lib/workflows/executor/pause-point-sql.ts new file mode 100644 index 00000000000..372b84b9b01 --- /dev/null +++ b/apps/sim/lib/workflows/executor/pause-point-sql.ts @@ -0,0 +1,21 @@ +import { pausedExecutions } from '@sim/db/schema' +import { sql } from 'drizzle-orm' + +/** + * Every pause point of a `paused_executions` row, as the derived table `point(value)`. + * + * `pause_points` is an object keyed by context id, or an array on legacy rows; both shapes are + * read, as `normalizePausePoints` reads them, and each set function only ever receives the shape + * it accepts. + */ +export const PAUSE_POINT_ROWS = sql`( + select value from jsonb_each( + case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'object' + then ${pausedExecutions.pausePoints} else '{}'::jsonb end + ) + union all + select value from jsonb_array_elements( + case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'array' + then ${pausedExecutions.pausePoints} else '[]'::jsonb end + ) +) as point(value)` diff --git a/apps/sim/lib/workflows/executor/paused-runs.ts b/apps/sim/lib/workflows/executor/paused-runs.ts index 91a90dbf37b..07219f5c584 100644 --- a/apps/sim/lib/workflows/executor/paused-runs.ts +++ b/apps/sim/lib/workflows/executor/paused-runs.ts @@ -14,6 +14,7 @@ import { resumeKeyset, textKey, } from '@/lib/api/list-query' +import { PAUSE_POINT_ROWS } from '@/lib/workflows/executor/pause-point-sql' /** A paused run of an active workflow, with the raw `pause_points` its detail is built from. */ interface WorkspacePausedRunRow { @@ -35,25 +36,8 @@ const PAUSED_RUN_KEYS: readonly KeysetKey[] = [ const POINT_WAITS_ON_HUMAN = sql`coalesce(point.value ->> 'pauseKind', 'human') <> 'time' and coalesce(point.value ->> 'resumeStatus', 'paused') = 'paused'` -/** - * A paused run with a pause point still waiting on a human. `pause_points` is an object keyed - * by context id, or an array on legacy rows; both shapes are read, as `normalizePausePoints` - * reads them, and each set function only ever receives the shape it accepts. - */ -const WAITS_ON_HUMAN = sql`exists ( - select 1 from ( - select value from jsonb_each( - case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'object' - then ${pausedExecutions.pausePoints} else '{}'::jsonb end - ) - union all - select value from jsonb_array_elements( - case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'array' - then ${pausedExecutions.pausePoints} else '[]'::jsonb end - ) - ) as point(value) - where ${POINT_WAITS_ON_HUMAN} -)` +/** A paused run with a pause point still waiting on a human. */ +const WAITS_ON_HUMAN = sql`exists (select 1 from ${PAUSE_POINT_ROWS} where ${POINT_WAITS_ON_HUMAN})` /** The cursor that resumes the list after `row`. */ export function pausedRunCursorKeys(row: WorkspacePausedRunRow): CursorKey[] { diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index 2568d584c18..1b2db5b9930 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -13,8 +13,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockSend = vi.fn() const capturedRequestHistories = vi.hoisted(() => [] as unknown[]) +const mockGetAwsCredentials = vi.hoisted(() => vi.fn(() => 'deployment-credential-chain')) + vi.mock('@/providers/conversation-history', () => providersConversationHistoryMock) +vi.mock('@/lib/core/config/aws', () => ({ getAwsCredentials: mockGetAwsCredentials })) + vi.mock('@aws-sdk/client-bedrock-runtime', () => ({ BedrockRuntimeClient: vi.fn().mockImplementation( class { @@ -87,6 +91,7 @@ describe('bedrockProvider credential handling', () => { beforeEach(() => { capturedRequestHistories.length = 0 clearProviderClientCacheForTests() + mockGetAwsCredentials.mockClear() mockSend.mockResolvedValue({ output: { message: { content: [{ text: 'response' }] } }, usage: { inputTokens: 10, outputTokens: 5 }, @@ -156,6 +161,93 @@ describe('bedrockProvider credential handling', () => { }) }) + it("signs only with a saved credential's session, never the deployment's own credentials", async () => { + await bedrockProvider.executeRequest({ + ...baseRequest, + bedrockSession: { + credentialId: 'cred-1', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + }, + }) + + expect(BedrockRuntimeClient).toHaveBeenLastCalledWith({ + region: 'us-east-1', + credentials: { + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + }, + }) + expect(mockGetAwsCredentials).not.toHaveBeenCalled() + }) + + it('prefers a saved credential over inline keys left on the request', async () => { + await bedrockProvider.executeRequest({ + ...baseRequest, + bedrockAccessKeyId: 'AKIAIOSFODNN7EXAMPLE', + bedrockSession: { + credentialId: 'cred-1', + accessKeyId: 'AKIASAVEDKEY00000001', + secretAccessKey: 'saved-secret', + }, + }) + + expect(BedrockRuntimeClient).toHaveBeenLastCalledWith({ + region: 'us-east-1', + credentials: { accessKeyId: 'AKIASAVEDKEY00000001', secretAccessKey: 'saved-secret' }, + }) + expect(mockGetAwsCredentials).not.toHaveBeenCalled() + }) + + it("keeps other clients warm while a role credential's STS sessions come and go", async () => { + const inlineKeys = { + bedrockAccessKeyId: 'AKIAIOSFODNN7EXAMPLE', + bedrockSecretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + } + await bedrockProvider.executeRequest({ ...baseRequest, ...inlineKeys }) + vi.mocked(BedrockRuntimeClient).mockClear() + + for (let call = 0; call < 1_000; call++) { + await bedrockProvider.executeRequest({ + ...baseRequest, + bedrockSession: { + credentialId: 'cred-role', + accessKeyId: `ASIASESSIONKEY${call}`, + secretAccessKey: `session-secret-${call}`, + sessionToken: `session-token-${call}`, + }, + }) + } + vi.mocked(BedrockRuntimeClient).mockClear() + + await bedrockProvider.executeRequest({ ...baseRequest, ...inlineKeys }) + expect(BedrockRuntimeClient).not.toHaveBeenCalled() + }) + + it('builds a new client when a long-lived saved key pair is corrected', async () => { + const session = { + credentialId: 'cred-1', + accessKeyId: 'AKIASAVEDKEY00000001', + secretAccessKey: 'mistyped-secret', + } + vi.mocked(BedrockRuntimeClient).mockClear() + + await bedrockProvider.executeRequest({ ...baseRequest, bedrockSession: session }) + await bedrockProvider.executeRequest({ + ...baseRequest, + bedrockSession: { ...session, secretAccessKey: 'corrected-secret' }, + }) + + expect(BedrockRuntimeClient).toHaveBeenCalledTimes(2) + expect(BedrockRuntimeClient).toHaveBeenLastCalledWith( + expect.objectContaining({ + credentials: { accessKeyId: 'AKIASAVEDKEY00000001', secretAccessKey: 'corrected-secret' }, + }) + ) + }) + it('omits temperature for catalog models that do not support it', async () => { vi.mocked(getModelCapabilities).mockReturnValueOnce({ maxOutputTokens: 128000 }) await bedrockProvider.executeRequest({ diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 47aa31619ee..1cf2f8931b2 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -13,6 +13,7 @@ import { type ToolUseBlock, } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { getErrorMessage, toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { getAwsCredentials } from '@/lib/core/config/aws' @@ -122,6 +123,60 @@ function enrichLastModelSegmentFromBedrockResponse( }) } +/** + * The credentials a Bedrock client signs with, and the part of its cache key + * that names them, or `null` when the client must not be cached. A saved + * credential's session is used alone, so selecting one never falls back to + * inline keys or the deployment's own AWS credentials. An STS session (assumed + * or keyless role) is fresh on every resolve, so its client is built for the one + * request rather than filling the shared client cache with entries never read + * again. A long-lived saved key pair is keyed by credential plus a digest of the + * key material, so a corrected pair gets a fresh client without the cache key + * holding a secret. + */ +function resolveBedrockClientCredentials(request: ProviderRequest): { + credentials: BedrockRuntimeClientConfig['credentials'] + credentialKey: string | null +} { + const session = request.bedrockSession + if (session) { + const { accessKeyId, secretAccessKey, sessionToken } = session + if (sessionToken) { + return { credentials: { accessKeyId, secretAccessKey, sessionToken }, credentialKey: null } + } + return { + credentials: { accessKeyId, secretAccessKey }, + credentialKey: [ + 'credential', + session.credentialId, + sha256Hex([accessKeyId, secretAccessKey].join('\0')), + ].join(':'), + } + } + + const hasAccessKey = Boolean(request.bedrockAccessKeyId) + const hasSecretKey = Boolean(request.bedrockSecretKey) + if (hasAccessKey !== hasSecretKey) { + throw new Error( + 'Both bedrockAccessKeyId and bedrockSecretKey must be provided together. ' + + 'Provide both for explicit credentials, or omit both to use the AWS default credential chain.' + ) + } + + if (request.bedrockAccessKeyId && request.bedrockSecretKey) { + // Key on the full credential (access key id + secret) so a corrected secret + // under the same access key id yields a fresh client rather than a stale one. + return { + credentials: { + accessKeyId: request.bedrockAccessKeyId, + secretAccessKey: request.bedrockSecretKey, + }, + credentialKey: `${request.bedrockAccessKeyId}:${request.bedrockSecretKey}`, + } + } + return { credentials: getAwsCredentials(), credentialKey: 'default-chain' } +} + export const bedrockProvider: ProviderConfig = { id: 'bedrock', name: 'AWS Bedrock', @@ -151,34 +206,13 @@ export const bedrockProvider: ProviderConfig = { region, }) - const hasAccessKey = Boolean(request.bedrockAccessKeyId) - const hasSecretKey = Boolean(request.bedrockSecretKey) - if (hasAccessKey !== hasSecretKey) { - throw new Error( - 'Both bedrockAccessKeyId and bedrockSecretKey must be provided together. ' + - 'Provide both for explicit credentials, or omit both to use the AWS default credential chain.' - ) - } - - const clientConfig: BedrockRuntimeClientConfig = { - region, - credentials: - request.bedrockAccessKeyId && request.bedrockSecretKey - ? { accessKeyId: request.bedrockAccessKeyId, secretAccessKey: request.bedrockSecretKey } - : getAwsCredentials(), - requestHandler: awsRequestHandler(), - } - - // Key on the full credential (access key id + secret) so a corrected secret - // under the same access key id yields a fresh client rather than a stale one. - const credentialKey = - request.bedrockAccessKeyId && request.bedrockSecretKey - ? `${request.bedrockAccessKeyId}:${request.bedrockSecretKey}` - : 'default-chain' - const client = getCachedProviderClient( - `bedrock::${region}::${credentialKey}`, - () => new BedrockRuntimeClient(clientConfig) - ) + const { credentials, credentialKey } = resolveBedrockClientCredentials(request) + const buildClient = () => + new BedrockRuntimeClient({ region, credentials, requestHandler: awsRequestHandler() }) + const client = + credentialKey === null + ? buildClient() + : getCachedProviderClient(`bedrock::${region}::${credentialKey}`, buildClient) const { messages, systemContent } = convertBedrockRequestHistory(request) const canonicalModelId = `bedrock/${getBedrockBaseModelId(request.model)}` diff --git a/apps/sim/providers/conversation-history.test.ts b/apps/sim/providers/conversation-history.test.ts index 1da34926b05..3fff3be32ff 100644 --- a/apps/sim/providers/conversation-history.test.ts +++ b/apps/sim/providers/conversation-history.test.ts @@ -63,6 +63,50 @@ describe('native history request binding', () => { ).not.toBe(initial) }) + it('binds a saved AWS credential, not the session it resolved to for one run', () => { + const session = (credentialId: string, accessKeyId: string) => ({ + credentialId, + accessKeyId, + secretAccessKey: `${accessKeyId}-secret`, + sessionToken: `${accessKeyId}-token`, + }) + const first = getConversationBinding('bedrock', { + ...request, + bedrockSession: session('aws-cred-1', 'ASIAFIRSTSESSION0001'), + }) + + expect( + getConversationBinding('bedrock', { + ...request, + bedrockSession: session('aws-cred-1', 'ASIANEXTSESSION00002'), + }) + ).toBe(first) + expect( + getConversationBinding('bedrock', { + ...request, + bedrockSession: session('aws-cred-2', 'ASIAFIRSTSESSION0001'), + }) + ).not.toBe(first) + expect(getConversationBinding('bedrock', request)).not.toBe(first) + }) + + it('ignores inline access keys left on a block that signs with a saved AWS credential', () => { + const savedCredentialRequest = (bedrockAccessKeyId: string, bedrockSecretKey: string) => ({ + ...request, + bedrockAccessKeyId, + bedrockSecretKey, + bedrockSession: { + credentialId: 'aws-cred-1', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + }, + }) + + expect( + getConversationBinding('bedrock', savedCredentialRequest('AKIAOLDKEY0000000001', 'old')) + ).toBe(getConversationBinding('bedrock', savedCredentialRequest('AKIANEWKEY0000000002', 'new'))) + }) + it('binds JSON-shaped parameter projection metadata for pending tool replay', () => { const tool: ProviderToolConfig = { id: 'lookup', diff --git a/apps/sim/providers/conversation-history.ts b/apps/sim/providers/conversation-history.ts index 5812a04ba98..644d2bfdd36 100644 --- a/apps/sim/providers/conversation-history.ts +++ b/apps/sim/providers/conversation-history.ts @@ -75,8 +75,11 @@ export function getConversationBinding(providerId: ProviderId, request: Provider context: request.context, account: { apiKey: request.apiKey, - accessKey: request.bedrockAccessKeyId, - secretKey: request.bedrockSecretKey, + // A saved credential signs alone and resolves to new session keys each run, so the + // credential is the account and inline keys left on the block are not. + ...(request.bedrockSession + ? { bedrockCredential: request.bedrockSession.credentialId } + : { accessKey: request.bedrockAccessKeyId, secretKey: request.bedrockSecretKey }), }, project: request.vertexProject, location: request.vertexLocation, diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 3be510f8771..281f67a9548 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -1,5 +1,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution' import { UserFailure } from '@/lib/core/errors/user-failure' +import type { AwsSessionCredentials } from '@/lib/credentials/aws-service-account.server' import type { CustomBlockInputFieldType } from '@/blocks/custom/build-config' import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types' @@ -193,6 +194,17 @@ export interface EvaluationInput { questions: unknown } +/** + * Signing credentials resolved from a saved AWS credential for one Bedrock + * request. `sessionToken` is present for STS sessions (assumed and keyless + * roles) and absent for long-lived keys; `credentialId` names the saved + * credential the session came from. + */ +export type BedrockCredentialSession = Pick< + AwsSessionCredentials, + 'accessKeyId' | 'secretAccessKey' | 'sessionToken' +> & { credentialId: string } + export interface ProviderRequest { evaluation?: EvaluationInput /** Server-installed stable identity resolver; never accepted from an API payload. */ @@ -239,6 +251,12 @@ export interface ProviderRequest { bedrockAccessKeyId?: string bedrockSecretKey?: string bedrockRegion?: string + /** + * A saved AWS credential the caller authorized and resolved server-side; never + * taken from block or tool input. When set, Bedrock signs with it alone: never + * with inline keys or the deployment's own AWS credentials. + */ + bedrockSession?: BedrockCredentialSession reasoningEffort?: string verbosity?: string thinkingLevel?: string diff --git a/apps/sim/public/changelog/browser-open-window-v1.jpg b/apps/sim/public/changelog/browser-open-window-v1.jpg new file mode 100644 index 00000000000..769134e2af0 Binary files /dev/null and b/apps/sim/public/changelog/browser-open-window-v1.jpg differ diff --git a/apps/sim/public/changelog/credentials-gateway-v1.jpg b/apps/sim/public/changelog/credentials-gateway-v1.jpg new file mode 100644 index 00000000000..11df2475db0 Binary files /dev/null and b/apps/sim/public/changelog/credentials-gateway-v1.jpg differ diff --git a/apps/sim/public/changelog/search-action-review-v1.jpg b/apps/sim/public/changelog/search-action-review-v1.jpg new file mode 100644 index 00000000000..147abd1d815 Binary files /dev/null and b/apps/sim/public/changelog/search-action-review-v1.jpg differ diff --git a/apps/sim/public/changelog/video-still-water-v1.jpg b/apps/sim/public/changelog/video-still-water-v1.jpg new file mode 100644 index 00000000000..bbc5f86829f Binary files /dev/null and b/apps/sim/public/changelog/video-still-water-v1.jpg differ diff --git a/apps/sim/public/changelog/workflow-paths-v1.jpg b/apps/sim/public/changelog/workflow-paths-v1.jpg new file mode 100644 index 00000000000..38d854d23d8 Binary files /dev/null and b/apps/sim/public/changelog/workflow-paths-v1.jpg differ diff --git a/apps/sim/public/library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/cover.jpg b/apps/sim/public/library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/cover.jpg new file mode 100644 index 00000000000..79aff265665 Binary files /dev/null and b/apps/sim/public/library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/cover.jpg differ diff --git a/apps/sim/public/library/how-to-build-shopify-ai-agent-orders-inventory/cover.jpg b/apps/sim/public/library/how-to-build-shopify-ai-agent-orders-inventory/cover.jpg new file mode 100644 index 00000000000..b98c68f2c43 Binary files /dev/null and b/apps/sim/public/library/how-to-build-shopify-ai-agent-orders-inventory/cover.jpg differ diff --git a/apps/sim/scripts/saved-credential-contract.test.ts b/apps/sim/scripts/saved-credential-contract.test.ts index f80dc64cd5e..5e692c735e0 100644 --- a/apps/sim/scripts/saved-credential-contract.test.ts +++ b/apps/sim/scripts/saved-credential-contract.test.ts @@ -239,6 +239,29 @@ describe('savedCredentialContractErrors', () => { expect(errors).toEqual([expect.stringContaining('accepts "any" credentials')]) }) + it.each([undefined, 'oauth', 'service-account'] as const)( + 'a mixed picker requires its tool to accept both credential kinds: %s', + (credentialKind) => { + const block = nativeBlock('probe_fixture') + block.subBlocks[0].credentialKind = 'any' + const errors = savedCredentialContractErrors( + block, + [], + nativeHostTool({ + required: true, + provider: PROVIDER, + credentialKind, + credentialParams: { accessToken: 'accessToken' }, + credentialSuppliedParams: ['accessToken'], + authoritativeParams: ['instanceUrl'], + }) + ) + expect(errors).toEqual( + credentialKind ? [expect.stringContaining('picker accepts both credential kinds')] : [] + ) + } + ) + it('fails a native block whose tool has no credential policy, so its token is never filled', () => { const errors = savedCredentialContractErrors( nativeBlock('probe_fixture'), @@ -293,10 +316,35 @@ describe('savedCredentialContractErrors', () => { ) expect(errors).toContainEqual( - expect.stringContaining('needs credentialParams to fill "accessToken"') + expect.stringContaining('tokenReplacesParams needs a credentialParams entry filled with') ) }) + it('passes a token-replaced sign-in when the token fills a param not named accessToken', () => { + const executable = { + ...operationTool('token_fixture', { + authToken: { type: 'string' }, + username: { type: 'string' }, + password: { type: 'string' }, + }), + oauth: { + required: false, + provider: PROVIDER, + credentialKind: 'service-account', + credentialParams: { authToken: 'accessToken' }, + tokenReplacesParams: ['username', 'password'], + credentialSuppliedParams: ['authToken', 'username', 'password'], + } satisfies OAuthConfig, + } + + const errors = savedCredentialContractErrors(nativeBlock('token_fixture'), [], { + metadata: () => executable as ToolMetadata, + executable: () => executable, + }) + + expect(errors).toEqual([]) + }) + it('still fails a credential-supplied secret that neither the credential nor its token fills', () => { const errors = savedCredentialContractErrors( nativeBlock('minting_fixture'), diff --git a/apps/sim/scripts/saved-credential-contract.ts b/apps/sim/scripts/saved-credential-contract.ts index aca2505a1da..f067061cfd4 100644 --- a/apps/sim/scripts/saved-credential-contract.ts +++ b/apps/sim/scripts/saved-credential-contract.ts @@ -45,6 +45,8 @@ const DESTINATION_PARAM = const OPERATION_PAYLOAD_PARAMS: Readonly> = { // The page the remote browser opens; the token goes to api.browser-use.com. browser_use_run_task: ['startUrl'], + // A CB Insights profile URL resolved by the lookup, sent in the body to api.cbinsights.com. + cbinsights_lookup_organizations: ['profileUrl'], // A label CrowdStrike records on the RTR session; the token goes to the credential's Falcon cloud. crowdstrike_init_rtr_session: ['origin'], // A guild id the operation never sends; it posts only to the channel's discord.com endpoint. @@ -245,12 +247,16 @@ export function savedCredentialContractErrors( tools: SavedCredentialToolLookup ): string[] { const errors: string[] = [] - const picker = block.subBlocks.find(isSavedCredentialPicker) - // A legacy block shares its successor's tools but takes the secret inline. - if (!picker && legacies.length === 0) return errors const takesSavedCredential = (block.tools?.access ?? []).some( (toolId) => (tools.metadata(toolId)?.oauth?.credentialSuppliedParams?.length ?? 0) > 0 ) + const picker = block.subBlocks.find( + (subBlock) => + isSavedCredentialPicker(subBlock) && + (subBlock.credentialKind === 'service-account' || takesSavedCredential) + ) + // A legacy block shares its successor's tools but takes the secret inline. + if (!picker && legacies.length === 0) return errors if (!takesSavedCredential && !picker) return errors if (!picker?.serviceId) { errors.push( @@ -300,7 +306,14 @@ export function savedCredentialContractErrors( } continue } - if (oauth.credentialKind !== 'service-account') { + if (picker.credentialKind === 'any' && oauth.credentialKind) { + errors.push( + `Block "${block.type}" → tool "${toolId}": picker accepts both credential kinds but the tool only accepts "${oauth.credentialKind}".` + ) + } else if ( + picker.credentialKind === 'service-account' && + oauth.credentialKind !== 'service-account' + ) { errors.push( `Block "${block.type}" → tool "${toolId}": accepts "${oauth.credentialKind ?? 'any'}" credentials, not the picker's service-account credential.` ) @@ -314,10 +327,10 @@ export function savedCredentialContractErrors( if (!OPERATION_RESOLVED_CREDENTIAL_PROVIDERS.has(oauth.provider)) { if ( oauth.tokenReplacesParams?.length && - oauth.credentialParams?.accessToken !== 'accessToken' + !Object.values(oauth.credentialParams ?? {}).includes('accessToken') ) { errors.push( - `Block "${block.type}" → tool "${toolId}": tokenReplacesParams needs credentialParams to fill "accessToken" with the minted token.` + `Block "${block.type}" → tool "${toolId}": tokenReplacesParams needs a credentialParams entry filled with the credential's token.` ) } const replacedByToken = new Set(oauth.tokenReplacesParams ?? []) diff --git a/apps/sim/scripts/test-organization-search-e2e.ts b/apps/sim/scripts/test-organization-search-e2e.ts index 8606ca7c6de..02193af5425 100644 --- a/apps/sim/scripts/test-organization-search-e2e.ts +++ b/apps/sim/scripts/test-organization-search-e2e.ts @@ -1039,10 +1039,14 @@ try { const handledRedirectCount = () => browserErrorEvents.reduce( (count, event) => - event.stage === 'Search URLs cannot change an agent conversation into an assistant' && - event.url === `${baseUrl.origin}${root}/search/chat/${agentId}` && - event.digest === `NEXT_REDIRECT;replace;${root}/chat/${agentId};307;` && - event.message.startsWith('Minified React error #419;') + event.message.startsWith('Minified React error #419;') && + ((event.stage === 'Search URLs cannot change an agent conversation into an assistant' && + event.url === `${baseUrl.origin}${root}/search/chat/${agentId}` && + event.digest === `NEXT_REDIRECT;replace;${root}/chat/${agentId};307;`) || + (event.stage === + 'Search-only members can reopen assistant history without access to Home chat' && + event.url === `${baseUrl.origin}${root}/home` && + event.digest === `NEXT_REDIRECT;replace;${root}/search;307;`)) ? count + event.count : count, 0 diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 72d18047f71..3a6a00ed61c 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -9,17 +9,23 @@ import { AgentMailBlock, AgentMailV2Block } from '@/blocks/blocks/agentmail' import { AgentPhoneBlock, AgentPhoneV2Block } from '@/blocks/blocks/agentphone' import { AhrefsBlock, AhrefsV2Block } from '@/blocks/blocks/ahrefs' import { AlgoliaBlock, AlgoliaV2Block } from '@/blocks/blocks/algolia' +import { AmplitudeBlock, AmplitudeV2Block } from '@/blocks/blocks/amplitude' import { ApifyBlock, ApifyV2Block } from '@/blocks/blocks/apify' import { ApolloBlock, ApolloV2Block } from '@/blocks/blocks/apollo' import { AppConfigBlock, AppConfigV2Block } from '@/blocks/blocks/appconfig' import { AshbyBlock, AshbyV2Block } from '@/blocks/blocks/ashby' import { AthenaBlock, AthenaV2Block } from '@/blocks/blocks/athena' +import { + AzureDataExplorerBlock, + AzureDataExplorerV2Block, +} from '@/blocks/blocks/azure_data_explorer' import { AzureDevOpsBlock, AzureDevOpsV2Block } from '@/blocks/blocks/azure_devops' import { BrexBlock, BrexV2Block } from '@/blocks/blocks/brex' import { BrightDataBlock, BrightDataV2Block } from '@/blocks/blocks/brightdata' import { BrowserUseBlock, BrowserUseV2Block } from '@/blocks/blocks/browser_use' import { BufferBlock, BufferV2Block } from '@/blocks/blocks/buffer' import { CalendlyBlock, CalendlyV2Block } from '@/blocks/blocks/calendly' +import { CbInsightsBlock, CbInsightsV2Block } from '@/blocks/blocks/cbinsights' import { CheckrBlock, CheckrV2Block } from '@/blocks/blocks/checkr' import { CirclebackBlock, CirclebackV2Block } from '@/blocks/blocks/circleback' import { ClerkBlock, ClerkV2Block } from '@/blocks/blocks/clerk' @@ -64,6 +70,7 @@ import { InstantlyBlock, InstantlyV2Block } from '@/blocks/blocks/instantly' import { IntercomV2Block, IntercomV3Block } from '@/blocks/blocks/intercom' import { JotformBlock, JotformV2Block } from '@/blocks/blocks/jotform' import { JupyterV2Block, JupyterV3Block } from '@/blocks/blocks/jupyter' +import { KalshiV2Block, KalshiV3Block } from '@/blocks/blocks/kalshi' import { LambdaBlock, LambdaV2Block } from '@/blocks/blocks/lambda' import { LangsmithBlock, LangsmithV2Block } from '@/blocks/blocks/langsmith' import { LaunchDarklyBlock, LaunchDarklyV2Block } from '@/blocks/blocks/launchdarkly' @@ -75,6 +82,8 @@ import { LumaBlock, LumaV2Block } from '@/blocks/blocks/luma' import { MailchimpBlock, MailchimpV2Block } from '@/blocks/blocks/mailchimp' import { MailgunBlock, MailgunV2Block } from '@/blocks/blocks/mailgun' import { Mem0Block, Mem0V2Block } from '@/blocks/blocks/mem0' +import { MSSQLBlock, MSSQLV2Block } from '@/blocks/blocks/mssql' +import { MySQLBlock, MySQLV2Block } from '@/blocks/blocks/mysql' import { NewRelicBlock, NewRelicV2Block } from '@/blocks/blocks/new_relic' import { OktaBlock, OktaV2Block } from '@/blocks/blocks/okta' import { OtterBlock, OtterV2Block } from '@/blocks/blocks/otter' @@ -82,10 +91,13 @@ import { PagerDutyBlock, PagerDutyV2Block } from '@/blocks/blocks/pagerduty' import { PersonaBlock, PersonaV2Block } from '@/blocks/blocks/persona' import { PlaneBlock, PlaneV2Block } from '@/blocks/blocks/plane' import { PlanetScaleBlock, PlanetScaleV2Block } from '@/blocks/blocks/planetscale' +import { PostgreSQLBlock, PostgreSQLV2Block } from '@/blocks/blocks/postgresql' import { ProfoundBlock, ProfoundV2Block } from '@/blocks/blocks/profound' import { PulseV2Block, PulseV3Block } from '@/blocks/blocks/pulse' +import { QdrantBlock, QdrantV2Block } from '@/blocks/blocks/qdrant' import { QuartrBlock, QuartrV2Block } from '@/blocks/blocks/quartr' import { QuiverV2Block, QuiverV3Block } from '@/blocks/blocks/quiver' +import { RailwayBlock, RailwayV2Block } from '@/blocks/blocks/railway' import { RB2BBlock, RB2BV2Block } from '@/blocks/blocks/rb2b' import { RDSBlock, RDSV2Block } from '@/blocks/blocks/rds' import { ReductoV2Block, ReductoV3Block } from '@/blocks/blocks/reducto' @@ -103,6 +115,7 @@ import { SESBlock, SESV2Block } from '@/blocks/blocks/ses' import { SimilarwebBlock, SimilarwebV2Block } from '@/blocks/blocks/similarweb' import { SixtyfourBlock, SixtyfourV2Block } from '@/blocks/blocks/sixtyfour' import { SmartleadBlock, SmartleadV2Block } from '@/blocks/blocks/smartlead' +import { SplunkBlock, SplunkV2Block } from '@/blocks/blocks/splunk' import { SportmonksBlock, SportmonksV2Block } from '@/blocks/blocks/sportmonks' import { SQSBlock, SQSV2Block } from '@/blocks/blocks/sqs' import { SquareBlock, SquareV2Block } from '@/blocks/blocks/square' @@ -341,6 +354,11 @@ const VERSIONS: Array<{ secretIds: ['apiKey', 'baseUrl'], optionalIds: ['baseUrl'], }, + { + legacy: AzureDataExplorerBlock, + successor: AzureDataExplorerV2Block, + secretIds: ['tenantId', 'clientId', 'clientSecret'], + }, { legacy: AzureDevOpsBlock, successor: AzureDevOpsV2Block, @@ -405,11 +423,68 @@ const VERSIONS: Array<{ secretIds: ['serviceTokenId', 'serviceToken'], }, { legacy: GongBlock, successor: GongV2Block, secretIds: ['accessKey', 'accessKeySecret'] }, + { + legacy: QdrantBlock, + successor: QdrantV2Block, + secretIds: ['url', 'apiKey'], + optionalIds: ['apiKey'], + legacyOperation: 'upsert', + }, + { + legacy: RailwayBlock, + successor: RailwayV2Block, + secretIds: ['apiKey', 'tokenType'], + optionalIds: ['tokenType'], + }, + { + legacy: CbInsightsBlock, + successor: CbInsightsV2Block, + secretIds: ['clientId', 'clientSecret'], + }, + { + legacy: SplunkBlock, + successor: SplunkV2Block, + secretIds: ['baseUrl', 'authToken', 'username', 'password'], + optionalIds: ['authToken', 'username', 'password'], + }, + { + legacy: AmplitudeBlock, + successor: AmplitudeV2Block, + secretIds: ['apiKey', 'secretKey', 'dataResidency'], + optionalIds: ['secretKey', 'dataResidency'], + legacyOperation: 'list_events', + }, { legacy: ServiceNowV2Block, successor: ServiceNowV3Block, secretIds: ['instanceUrl', 'username', 'password'], }, + { + legacy: PostgreSQLBlock, + successor: PostgreSQLV2Block, + secretIds: ['host', 'port', 'database', 'username', 'password', 'ssl'], + optionalIds: ['port', 'ssl'], + }, + { + legacy: MySQLBlock, + successor: MySQLV2Block, + secretIds: ['host', 'port', 'database', 'username', 'password', 'ssl'], + optionalIds: ['port', 'database', 'ssl'], + }, + { + legacy: MSSQLBlock, + successor: MSSQLV2Block, + secretIds: [ + 'host', + 'port', + 'database', + 'username', + 'password', + 'encrypt', + 'trustServerCertificate', + ], + optionalIds: ['port', 'encrypt', 'trustServerCertificate'], + }, ] function valuesFor(ids: readonly string[], prefix: string): Record { @@ -429,10 +504,14 @@ function firstOperation(config: BlockConfig): string | undefined { return operations(config)[0] } +/** One title per field id: a field repeated per operation (Qdrant's URL) is still one field. */ function fieldTitles(config: BlockConfig, ids: readonly string[]): string[] { - return config.subBlocks - .filter((subBlock) => ids.includes(subBlock.id) && !isTriggerModeSubBlock(subBlock)) - .map((subBlock) => subBlock.title ?? subBlock.id) + const titles = new Map() + for (const subBlock of config.subBlocks) { + if (!ids.includes(subBlock.id) || isTriggerModeSubBlock(subBlock)) continue + if (!titles.has(subBlock.id)) titles.set(subBlock.id, subBlock.title ?? subBlock.id) + } + return [...titles.values()] } function buildBlock(config: BlockConfig, values: Record): BlockState { @@ -556,6 +635,57 @@ describe.each(VERSIONS)('$successor.type credential-only block', ({ successor, s }) }) +describe('kalshi_v3 signed and public calls', () => { + const SECRET_IDS = ['keyId', 'privateKey'] + + it('kalshi_v2 still serializes its inline key for signed calls', () => { + vi.spyOn(blocks, 'getBlock').mockReturnValue(KalshiV2Block) + const params = serialize( + buildBlock(KalshiV2Block, { operation: 'get_balance', ...valuesFor(SECRET_IDS, 'inline') }) + ) + + for (const id of SECRET_IDS) expect(params[id]).toBe(`inline-${id}`) + expect(params.oauthCredential).toBeUndefined() + }) + + it('requires a saved credential for signed calls only', () => { + vi.spyOn(blocks, 'getBlock').mockReturnValue(KalshiV3Block) + const credentialTitle = fieldTitles(KalshiV3Block, ['credential'])[0] + + expect( + missingRequiredFields(buildBlock(KalshiV3Block, { operation: 'get_balance' })) + ).toContain(credentialTitle) + expect( + missingRequiredFields(buildBlock(KalshiV3Block, { operation: 'get_markets' })) + ).not.toContain(credentialTitle) + }) + + it('sends the saved credential and never inline secrets left in its state', () => { + vi.spyOn(blocks, 'getBlock').mockReturnValue(KalshiV3Block) + const params = serialize( + buildBlock(KalshiV3Block, { + operation: 'create_order', + credential: 'cred-1', + ...valuesFor(SECRET_IDS, 'stale'), + }) + ) + + expect(params.oauthCredential).toBe('cred-1') + for (const id of SECRET_IDS) expect(params[id]).toBeUndefined() + }) + + it('never asks for the inline key, in any operation', () => { + vi.spyOn(blocks, 'getBlock').mockReturnValue(KalshiV3Block) + for (const operation of operations(KalshiV3Block)) { + const message = missingRequiredFields( + buildBlock(KalshiV3Block, { operation: operation ?? '', credential: 'cred-1' }) + ) + + for (const id of SECRET_IDS) expect(message, operation).not.toContain(id) + } + }) +}) + describe('sts_v2 unsigned calls', () => { beforeEach(() => { vi.spyOn(blocks, 'getBlock').mockReturnValue(STSV2Block) diff --git a/apps/sim/tools/amplitude/event_segmentation.ts b/apps/sim/tools/amplitude/event_segmentation.ts index 394adf07ccf..25a344e4cf1 100644 --- a/apps/sim/tools/amplitude/event_segmentation.ts +++ b/apps/sim/tools/amplitude/event_segmentation.ts @@ -3,6 +3,7 @@ import type { AmplitudeEventSegmentationResponse, } from '@/tools/amplitude/types' import { getDashboardHost } from '@/tools/amplitude/utils' +import { AMPLITUDE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const eventSegmentationTool: ToolConfig< @@ -14,6 +15,7 @@ export const eventSegmentationTool: ToolConfig< description: 'Query event analytics data with segmentation. Get event counts, uniques, averages, and more.', version: '1.0.0', + oauth: AMPLITUDE_CREDENTIAL_OAUTH, params: { apiKey: { diff --git a/apps/sim/tools/amplitude/funnels.ts b/apps/sim/tools/amplitude/funnels.ts index f00036320f4..cae0840465d 100644 --- a/apps/sim/tools/amplitude/funnels.ts +++ b/apps/sim/tools/amplitude/funnels.ts @@ -1,6 +1,7 @@ import { isRecordLike } from '@sim/utils/object' import type { AmplitudeFunnelsParams, AmplitudeFunnelsResponse } from '@/tools/amplitude/types' import { getDashboardHost } from '@/tools/amplitude/utils' +import { AMPLITUDE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const funnelsTool: ToolConfig = { @@ -8,6 +9,7 @@ export const funnelsTool: ToolConfig = { @@ -10,6 +11,7 @@ export const getRevenueTool: ToolConfig = { @@ -11,6 +12,7 @@ export const listEventsTool: ToolConfig = { @@ -7,6 +8,7 @@ export const retentionTool: ToolConfig = { @@ -7,6 +8,7 @@ export const sendEventTool: ToolConfig = @@ -11,8 +12,16 @@ export const userProfileTool: ToolConfig ({ - Authorization: `Api-Key ${params.secretKey}`, - }), + headers: (params) => { + const apiKey = params.apiKey?.trim() + // Without an API key (an inline block that never asked for one), keep the secret-key-only header. + return { + Authorization: apiKey + ? `Basic ${btoa(`${apiKey}:${params.secretKey}`)}` + : `Api-Key ${params.secretKey}`, + } + }, }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/amplitude/user_search.ts b/apps/sim/tools/amplitude/user_search.ts index 49a38ce4693..d5abc90fbb6 100644 --- a/apps/sim/tools/amplitude/user_search.ts +++ b/apps/sim/tools/amplitude/user_search.ts @@ -3,6 +3,7 @@ import type { AmplitudeUserSearchResponse, } from '@/tools/amplitude/types' import { getDashboardHost } from '@/tools/amplitude/utils' +import { AMPLITUDE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const userSearchTool: ToolConfig = { @@ -11,6 +12,7 @@ export const userSearchTool: ToolConfig ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloAccountBulkCreateParams) => { const body: Record = { diff --git a/apps/sim/tools/apollo/account_bulk_update.ts b/apps/sim/tools/apollo/account_bulk_update.ts index f9ab7128553..18802f87834 100644 --- a/apps/sim/tools/apollo/account_bulk_update.ts +++ b/apps/sim/tools/apollo/account_bulk_update.ts @@ -2,6 +2,7 @@ import type { ApolloAccountBulkUpdateParams, ApolloAccountBulkUpdateResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,6 +18,12 @@ export const apolloAccountBulkUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -70,7 +77,7 @@ export const apolloAccountBulkUpdateTool: ToolConfig< headers: (params: ApolloAccountBulkUpdateParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloAccountBulkUpdateParams) => { const body: Record = {} diff --git a/apps/sim/tools/apollo/account_create.ts b/apps/sim/tools/apollo/account_create.ts index 70ad27ecce2..bb91ef681c0 100644 --- a/apps/sim/tools/apollo/account_create.ts +++ b/apps/sim/tools/apollo/account_create.ts @@ -1,4 +1,5 @@ import type { ApolloAccountCreateParams, ApolloAccountCreateResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -13,6 +14,12 @@ export const apolloAccountCreateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -69,7 +76,7 @@ export const apolloAccountCreateTool: ToolConfig< headers: (params: ApolloAccountCreateParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloAccountCreateParams) => { const body: Record = { name: params.name } diff --git a/apps/sim/tools/apollo/account_search.ts b/apps/sim/tools/apollo/account_search.ts index 1b2e8598c30..62cac583c10 100644 --- a/apps/sim/tools/apollo/account_search.ts +++ b/apps/sim/tools/apollo/account_search.ts @@ -1,4 +1,5 @@ import type { ApolloAccountSearchParams, ApolloAccountSearchResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,6 +15,12 @@ export const apolloAccountSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -71,7 +78,7 @@ export const apolloAccountSearchTool: ToolConfig< headers: (params: ApolloAccountSearchParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloAccountSearchParams) => { const body: Record = { diff --git a/apps/sim/tools/apollo/account_update.ts b/apps/sim/tools/apollo/account_update.ts index bf5278bbb10..85bc5f47061 100644 --- a/apps/sim/tools/apollo/account_update.ts +++ b/apps/sim/tools/apollo/account_update.ts @@ -1,4 +1,5 @@ import type { ApolloAccountUpdateParams, ApolloAccountUpdateResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -13,6 +14,12 @@ export const apolloAccountUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -76,7 +83,7 @@ export const apolloAccountUpdateTool: ToolConfig< headers: (params: ApolloAccountUpdateParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloAccountUpdateParams) => { const body: Record = {} diff --git a/apps/sim/tools/apollo/contact_bulk_create.ts b/apps/sim/tools/apollo/contact_bulk_create.ts index 1437cd169e8..7f83e11575e 100644 --- a/apps/sim/tools/apollo/contact_bulk_create.ts +++ b/apps/sim/tools/apollo/contact_bulk_create.ts @@ -2,6 +2,7 @@ import type { ApolloContactBulkCreateParams, ApolloContactBulkCreateResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,6 +18,12 @@ export const apolloContactBulkCreateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -51,7 +58,7 @@ export const apolloContactBulkCreateTool: ToolConfig< headers: (params: ApolloContactBulkCreateParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloContactBulkCreateParams) => { const body: Record = { diff --git a/apps/sim/tools/apollo/contact_bulk_update.ts b/apps/sim/tools/apollo/contact_bulk_update.ts index d54d1acf8aa..e7e3c4152b1 100644 --- a/apps/sim/tools/apollo/contact_bulk_update.ts +++ b/apps/sim/tools/apollo/contact_bulk_update.ts @@ -2,6 +2,7 @@ import type { ApolloContactBulkUpdateParams, ApolloContactBulkUpdateResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,6 +18,12 @@ export const apolloContactBulkUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -51,7 +58,7 @@ export const apolloContactBulkUpdateTool: ToolConfig< headers: (params: ApolloContactBulkUpdateParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloContactBulkUpdateParams) => { const body: Record = {} diff --git a/apps/sim/tools/apollo/contact_create.ts b/apps/sim/tools/apollo/contact_create.ts index 5e4ba22efb0..a0ee5ec827c 100644 --- a/apps/sim/tools/apollo/contact_create.ts +++ b/apps/sim/tools/apollo/contact_create.ts @@ -1,4 +1,5 @@ import type { ApolloContactCreateParams, ApolloContactCreateResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -13,6 +14,12 @@ export const apolloContactCreateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -136,7 +143,7 @@ export const apolloContactCreateTool: ToolConfig< headers: (params: ApolloContactCreateParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloContactCreateParams) => { const body: Record = { diff --git a/apps/sim/tools/apollo/contact_search.ts b/apps/sim/tools/apollo/contact_search.ts index 3afa69ea3ed..db34f61c14c 100644 --- a/apps/sim/tools/apollo/contact_search.ts +++ b/apps/sim/tools/apollo/contact_search.ts @@ -1,4 +1,5 @@ import type { ApolloContactSearchParams, ApolloContactSearchResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -13,6 +14,12 @@ export const apolloContactSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -70,7 +77,7 @@ export const apolloContactSearchTool: ToolConfig< headers: (params: ApolloContactSearchParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloContactSearchParams) => { const body: Record = { diff --git a/apps/sim/tools/apollo/contact_update.ts b/apps/sim/tools/apollo/contact_update.ts index 6e746b85362..aebff7c4c3e 100644 --- a/apps/sim/tools/apollo/contact_update.ts +++ b/apps/sim/tools/apollo/contact_update.ts @@ -1,4 +1,5 @@ import type { ApolloContactUpdateParams, ApolloContactUpdateResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -13,6 +14,12 @@ export const apolloContactUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -137,7 +144,7 @@ export const apolloContactUpdateTool: ToolConfig< headers: (params: ApolloContactUpdateParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloContactUpdateParams) => { const body: Record = {} diff --git a/apps/sim/tools/apollo/email_accounts.ts b/apps/sim/tools/apollo/email_accounts.ts index 99d0a461a94..84b2404f155 100644 --- a/apps/sim/tools/apollo/email_accounts.ts +++ b/apps/sim/tools/apollo/email_accounts.ts @@ -1,4 +1,5 @@ import type { ApolloEmailAccountsParams, ApolloEmailAccountsResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -13,6 +14,12 @@ export const apolloEmailAccountsTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -27,7 +34,7 @@ export const apolloEmailAccountsTool: ToolConfig< headers: (params: ApolloEmailAccountsParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/opportunity_create.ts b/apps/sim/tools/apollo/opportunity_create.ts index 852de26a684..deae8c5dbdd 100644 --- a/apps/sim/tools/apollo/opportunity_create.ts +++ b/apps/sim/tools/apollo/opportunity_create.ts @@ -2,6 +2,7 @@ import type { ApolloOpportunityCreateParams, ApolloOpportunityCreateResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -16,6 +17,12 @@ export const apolloOpportunityCreateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -72,7 +79,7 @@ export const apolloOpportunityCreateTool: ToolConfig< headers: (params: ApolloOpportunityCreateParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloOpportunityCreateParams) => { const body: Record = { name: params.name } diff --git a/apps/sim/tools/apollo/opportunity_get.ts b/apps/sim/tools/apollo/opportunity_get.ts index 45b791a0108..888976cfbdc 100644 --- a/apps/sim/tools/apollo/opportunity_get.ts +++ b/apps/sim/tools/apollo/opportunity_get.ts @@ -1,4 +1,5 @@ import type { ApolloOpportunityGetParams, ApolloOpportunityGetResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -13,6 +14,12 @@ export const apolloOpportunityGetTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -34,7 +41,7 @@ export const apolloOpportunityGetTool: ToolConfig< headers: (params: ApolloOpportunityGetParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/opportunity_search.ts b/apps/sim/tools/apollo/opportunity_search.ts index 9ef3f72a0a9..bf75c8b2182 100644 --- a/apps/sim/tools/apollo/opportunity_search.ts +++ b/apps/sim/tools/apollo/opportunity_search.ts @@ -2,6 +2,7 @@ import type { ApolloOpportunitySearchParams, ApolloOpportunitySearchResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -16,6 +17,12 @@ export const apolloOpportunitySearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -53,7 +60,7 @@ export const apolloOpportunitySearchTool: ToolConfig< method: 'GET', headers: (params: ApolloOpportunitySearchParams) => ({ 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/opportunity_update.ts b/apps/sim/tools/apollo/opportunity_update.ts index 8360b26ae1c..b695f47c38d 100644 --- a/apps/sim/tools/apollo/opportunity_update.ts +++ b/apps/sim/tools/apollo/opportunity_update.ts @@ -2,6 +2,7 @@ import type { ApolloOpportunityUpdateParams, ApolloOpportunityUpdateResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -16,6 +17,12 @@ export const apolloOpportunityUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -73,7 +80,7 @@ export const apolloOpportunityUpdateTool: ToolConfig< headers: (params: ApolloOpportunityUpdateParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloOpportunityUpdateParams) => { const body: Record = {} diff --git a/apps/sim/tools/apollo/organization_bulk_enrich.ts b/apps/sim/tools/apollo/organization_bulk_enrich.ts index 3de00fb0096..2040e9e1225 100644 --- a/apps/sim/tools/apollo/organization_bulk_enrich.ts +++ b/apps/sim/tools/apollo/organization_bulk_enrich.ts @@ -2,6 +2,7 @@ import type { ApolloOrganizationBulkEnrichParams, ApolloOrganizationBulkEnrichResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -16,6 +17,12 @@ export const apolloOrganizationBulkEnrichTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -43,7 +50,7 @@ export const apolloOrganizationBulkEnrichTool: ToolConfig< method: 'POST', headers: (params: ApolloOrganizationBulkEnrichParams) => ({ 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/organization_enrich.ts b/apps/sim/tools/apollo/organization_enrich.ts index 3f84d5a02c1..b6bb9c5b57b 100644 --- a/apps/sim/tools/apollo/organization_enrich.ts +++ b/apps/sim/tools/apollo/organization_enrich.ts @@ -2,6 +2,7 @@ import type { ApolloOrganizationEnrichParams, ApolloOrganizationEnrichResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -16,6 +17,12 @@ export const apolloOrganizationEnrichTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -42,7 +49,7 @@ export const apolloOrganizationEnrichTool: ToolConfig< method: 'GET', headers: (params: ApolloOrganizationEnrichParams) => ({ 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/organization_search.ts b/apps/sim/tools/apollo/organization_search.ts index 7d57bf7a60c..efa732a7bcd 100644 --- a/apps/sim/tools/apollo/organization_search.ts +++ b/apps/sim/tools/apollo/organization_search.ts @@ -2,6 +2,7 @@ import type { ApolloOrganizationSearchParams, ApolloOrganizationSearchResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -16,6 +17,12 @@ export const apolloOrganizationSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -104,7 +111,7 @@ export const apolloOrganizationSearchTool: ToolConfig< method: 'POST', headers: (params: ApolloOrganizationSearchParams) => ({ 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/people_bulk_enrich.ts b/apps/sim/tools/apollo/people_bulk_enrich.ts index f64e78ff89c..4271de39a32 100644 --- a/apps/sim/tools/apollo/people_bulk_enrich.ts +++ b/apps/sim/tools/apollo/people_bulk_enrich.ts @@ -2,6 +2,7 @@ import type { ApolloPeopleBulkEnrichParams, ApolloPeopleBulkEnrichResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -16,6 +17,12 @@ export const apolloPeopleBulkEnrichTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -68,7 +75,7 @@ export const apolloPeopleBulkEnrichTool: ToolConfig< headers: (params: ApolloPeopleBulkEnrichParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloPeopleBulkEnrichParams) => ({ details: params.people.slice(0, 10), diff --git a/apps/sim/tools/apollo/people_enrich.ts b/apps/sim/tools/apollo/people_enrich.ts index 1ca6daca13d..aa775186370 100644 --- a/apps/sim/tools/apollo/people_enrich.ts +++ b/apps/sim/tools/apollo/people_enrich.ts @@ -1,4 +1,5 @@ import type { ApolloPeopleEnrichParams, ApolloPeopleEnrichResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -13,6 +14,12 @@ export const apolloPeopleEnrichTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -121,7 +128,7 @@ export const apolloPeopleEnrichTool: ToolConfig< method: 'POST', headers: (params: ApolloPeopleEnrichParams) => ({ 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/people_search.test.ts b/apps/sim/tools/apollo/people_search.test.ts new file mode 100644 index 00000000000..31585005257 --- /dev/null +++ b/apps/sim/tools/apollo/people_search.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { apolloPeopleSearchTool } from '@/tools/apollo/people_search' +import type { ApolloPeopleSearchParams } from '@/tools/apollo/types' + +const LEGACY_URL = 'https://api.apollo.io/api/v1/mixed_people/search' +const API_SEARCH_URL = 'https://api.apollo.io/api/v1/mixed_people/api_search' + +function resolveUrl(params: ApolloPeopleSearchParams): string { + const { url } = apolloPeopleSearchTool.request + return typeof url === 'function' ? url(params) : url +} + +async function transform(body: unknown, params: ApolloPeopleSearchParams) { + const { transformResponse } = apolloPeopleSearchTool + if (!transformResponse) throw new Error('apollo_people_search has no transformResponse') + return transformResponse(new Response(JSON.stringify(body), { status: 200 }), params) +} + +describe('apollo_people_search endpoint selection', () => { + it.concurrent.each(['oauth', 'managed_oauth'])( + 'sends %s credentials to the api_search endpoint their scope grants', + (credentialType) => { + expect(resolveUrl({ apiKey: 'token', credentialType })).toBe(API_SEARCH_URL) + } + ) + + it.concurrent.each([undefined, 'api_key', 'service_account'])( + 'keeps credential type %s on the legacy endpoint', + (credentialType) => { + expect(resolveUrl({ apiKey: 'key', credentialType })).toBe(LEGACY_URL) + } + ) +}) + +describe('apollo_people_search response mapping', () => { + it.concurrent('reports the requested page when api_search omits pagination', async () => { + const result = await transform( + { total_entries: 812, people: [{ id: 'p1' }] }, + { apiKey: 'token', credentialType: 'oauth', page: 3, per_page: 50 } + ) + + expect(result.output).toEqual({ + people: [{ id: 'p1' }], + page: 3, + per_page: 50, + total_entries: 812, + }) + }) + + it.concurrent('reports the clamped page size the request actually sent', async () => { + const result = await transform( + { total_entries: 5, people: [] }, + { apiKey: 'token', credentialType: 'oauth', per_page: 500 } + ) + + expect(result.output.page).toBe(1) + expect(result.output.per_page).toBe(100) + }) + + it.concurrent('reports page 1, the page the request sent, when page is 0', async () => { + const result = await transform( + { total_entries: 5, people: [] }, + { apiKey: 'token', credentialType: 'oauth', page: 0 } + ) + + expect(result.output.page).toBe(1) + }) + + it.concurrent('reads the nested pagination the legacy endpoint returns', async () => { + const result = await transform( + { people: [{ id: 'p2' }], pagination: { page: 2, per_page: 10, total_entries: 40 } }, + { apiKey: 'key', page: 7, per_page: 25 } + ) + + expect(result.output).toEqual({ + people: [{ id: 'p2' }], + page: 2, + per_page: 10, + total_entries: 40, + }) + }) +}) diff --git a/apps/sim/tools/apollo/people_search.ts b/apps/sim/tools/apollo/people_search.ts index 417ce8b589d..8bb9a5ace4a 100644 --- a/apps/sim/tools/apollo/people_search.ts +++ b/apps/sim/tools/apollo/people_search.ts @@ -1,7 +1,15 @@ import type { ApolloPeopleSearchParams, ApolloPeopleSearchResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders, isApolloOAuthCredential } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' +const DEFAULT_PER_PAGE = 25 +const MAX_PER_PAGE = 100 + +function requestedPerPage(params: ApolloPeopleSearchParams | undefined): number { + return Math.min(params?.per_page || DEFAULT_PER_PAGE, MAX_PER_PAGE) +} + export const apolloPeopleSearchTool: ToolConfig< ApolloPeopleSearchParams, ApolloPeopleSearchResponse @@ -13,6 +21,12 @@ export const apolloPeopleSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -105,17 +119,25 @@ export const apolloPeopleSearchTool: ToolConfig< }, request: { - url: 'https://api.apollo.io/api/v1/mixed_people/search', + /** + * Apollo grants OAuth apps only the `mixed_people_api_search` scope, which covers + * `/mixed_people/api_search`; API keys stay on the legacy endpoint, whose results + * still carry contact details that api_search omits. + */ + url: (params: ApolloPeopleSearchParams) => + isApolloOAuthCredential(params.credentialType) + ? 'https://api.apollo.io/api/v1/mixed_people/api_search' + : 'https://api.apollo.io/api/v1/mixed_people/search', method: 'POST', headers: (params: ApolloPeopleSearchParams) => ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloPeopleSearchParams) => { const body: Record = { page: params.page || 1, - per_page: Math.min(params.per_page || 25, 100), + per_page: requestedPerPage(params), } if (params.person_titles && params.person_titles.length > 0) { @@ -159,7 +181,7 @@ export const apolloPeopleSearchTool: ToolConfig< }, }, - transformResponse: async (response: Response) => { + transformResponse: async (response: Response, params?: ApolloPeopleSearchParams) => { if (!response.ok) { const errorText = await response.text() throw new Error(`Apollo API error: ${response.status} - ${errorText}`) @@ -167,16 +189,15 @@ export const apolloPeopleSearchTool: ToolConfig< const data = await response.json() - // The legacy /mixed_people/search endpoint nests pagination under `pagination`, - // while api_search returns these top-level. Read both shapes defensively. + // The legacy endpoint nests pagination; api_search returns only top-level total_entries. const pagination = data.pagination ?? {} return { success: true, output: { people: data.people || [], - page: pagination.page ?? data.page ?? 1, - per_page: pagination.per_page ?? data.per_page ?? 25, + page: pagination.page ?? data.page ?? (params?.page || 1), + per_page: pagination.per_page ?? data.per_page ?? requestedPerPage(params), total_entries: pagination.total_entries ?? data.total_entries ?? 0, }, } diff --git a/apps/sim/tools/apollo/sequence_add_contacts.ts b/apps/sim/tools/apollo/sequence_add_contacts.ts index f4c452c2bc4..718f191358b 100644 --- a/apps/sim/tools/apollo/sequence_add_contacts.ts +++ b/apps/sim/tools/apollo/sequence_add_contacts.ts @@ -2,6 +2,7 @@ import type { ApolloSequenceAddContactsParams, ApolloSequenceAddContactsResponse, } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -16,6 +17,12 @@ export const apolloSequenceAddContactsTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -202,7 +209,7 @@ export const apolloSequenceAddContactsTool: ToolConfig< method: 'POST', headers: (params: ApolloSequenceAddContactsParams) => ({ 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/sequence_search.ts b/apps/sim/tools/apollo/sequence_search.ts index a56337cf665..7d284e7cae5 100644 --- a/apps/sim/tools/apollo/sequence_search.ts +++ b/apps/sim/tools/apollo/sequence_search.ts @@ -1,4 +1,5 @@ import type { ApolloSequenceSearchParams, ApolloSequenceSearchResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -13,6 +14,12 @@ export const apolloSequenceSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { + credentialType: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Stored credential type supplied by credential resolution', + }, apiKey: { type: 'string', required: true, @@ -50,7 +57,7 @@ export const apolloSequenceSearchTool: ToolConfig< method: 'POST', headers: (params: ApolloSequenceSearchParams) => ({ 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/task_create.ts b/apps/sim/tools/apollo/task_create.ts index 287a0ac5647..cfb318e9aab 100644 --- a/apps/sim/tools/apollo/task_create.ts +++ b/apps/sim/tools/apollo/task_create.ts @@ -1,4 +1,5 @@ import type { ApolloTaskCreateParams, ApolloTaskCreateResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -10,6 +11,12 @@ export const apolloTaskCreateTool: ToolConfig ({ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), body: (params: ApolloTaskCreateParams) => { const body: Record = { diff --git a/apps/sim/tools/apollo/task_search.ts b/apps/sim/tools/apollo/task_search.ts index 0e940ad192d..6082f5da82f 100644 --- a/apps/sim/tools/apollo/task_search.ts +++ b/apps/sim/tools/apollo/task_search.ts @@ -1,4 +1,5 @@ import type { ApolloTaskSearchParams, ApolloTaskSearchResponse } from '@/tools/apollo/types' +import { getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -10,6 +11,12 @@ export const apolloTaskSearchTool: ToolConfig ({ 'Cache-Control': 'no-cache', - 'X-Api-Key': params.apiKey, + ...getApolloAuthHeaders(params), }), }, diff --git a/apps/sim/tools/apollo/types.ts b/apps/sim/tools/apollo/types.ts index 480b82abb04..0acbaa2f599 100644 --- a/apps/sim/tools/apollo/types.ts +++ b/apps/sim/tools/apollo/types.ts @@ -114,6 +114,7 @@ interface ApolloOpportunity { interface ApolloBaseParams { apiKey: string + credentialType?: string } // People Search Types diff --git a/apps/sim/tools/apollo/utils.ts b/apps/sim/tools/apollo/utils.ts new file mode 100644 index 00000000000..1ff2c17cdd8 --- /dev/null +++ b/apps/sim/tools/apollo/utils.ts @@ -0,0 +1,16 @@ +interface ApolloAuthentication { + apiKey: string + credentialType?: string +} + +/** Whether the resolved credential is an Apollo OAuth access token rather than an API key. */ +export function isApolloOAuthCredential(credentialType: string | undefined): boolean { + return credentialType === 'oauth' || credentialType === 'managed_oauth' +} + +/** Preserves API-key authentication while sending saved OAuth tokens as bearer credentials. */ +export function getApolloAuthHeaders(params: ApolloAuthentication): Record { + return isApolloOAuthCredential(params.credentialType) + ? { Authorization: `Bearer ${params.apiKey}` } + : { 'X-Api-Key': params.apiKey } +} diff --git a/apps/sim/tools/athena/batch_get_query_execution.ts b/apps/sim/tools/athena/batch_get_query_execution.ts index 19249a70de8..df95c795adb 100644 --- a/apps/sim/tools/athena/batch_get_query_execution.ts +++ b/apps/sim/tools/athena/batch_get_query_execution.ts @@ -138,6 +138,50 @@ export const batchGetQueryExecutionTool: InternalToolConfig< description: 'S3 location of query results', optional: true, }, + servicePreProcessingTimeInMillis: { + type: 'number', + description: + 'Time Athena spent preprocessing the query before submitting it, in milliseconds', + optional: true, + }, + serviceProcessingTimeInMillis: { + type: 'number', + description: + 'Time Athena spent finalizing and publishing results after execution, in milliseconds', + optional: true, + }, + dataManifestLocation: { + type: 'string', + description: 'S3 location of the manifest file listing files written by the query', + optional: true, + }, + reusedPreviousResult: { + type: 'boolean', + description: 'Whether the query reused a previous result instead of scanning data', + optional: true, + }, + athenaError: { + type: 'json', + description: + 'Failure details for a FAILED query (errorCategory: 1 system, 2 user, 3 other; errorType; retryable; errorMessage)', + optional: true, + }, + executionParameters: { + type: 'array', + description: "Values supplied for the query's ? placeholders", + optional: true, + }, + substatementType: { + type: 'string', + description: + 'Kind of query statement that was run (e.g., SELECT, INSERT, CREATE_TABLE)', + optional: true, + }, + engineVersion: { + type: 'json', + description: 'Engine version used (selectedEngineVersion, effectiveEngineVersion)', + optional: true, + }, }, }, }, diff --git a/apps/sim/tools/athena/get_query_execution.ts b/apps/sim/tools/athena/get_query_execution.ts index 1f6896b5e8a..0f77b63cc49 100644 --- a/apps/sim/tools/athena/get_query_execution.ts +++ b/apps/sim/tools/athena/get_query_execution.ts @@ -75,6 +75,14 @@ export const getQueryExecutionTool: InternalToolConfig< queryQueueTimeInMillis: data.output.queryQueueTimeInMillis ?? null, totalExecutionTimeInMillis: data.output.totalExecutionTimeInMillis ?? null, outputLocation: data.output.outputLocation ?? null, + servicePreProcessingTimeInMillis: data.output.servicePreProcessingTimeInMillis ?? null, + serviceProcessingTimeInMillis: data.output.serviceProcessingTimeInMillis ?? null, + dataManifestLocation: data.output.dataManifestLocation ?? null, + reusedPreviousResult: data.output.reusedPreviousResult ?? null, + athenaError: data.output.athenaError ?? null, + executionParameters: data.output.executionParameters ?? null, + substatementType: data.output.substatementType ?? null, + engineVersion: data.output.engineVersion ?? null, }, } }, @@ -139,5 +147,48 @@ export const getQueryExecutionTool: InternalToolConfig< description: 'S3 location of query results', optional: true, }, + servicePreProcessingTimeInMillis: { + type: 'number', + description: + 'Time Athena spent preprocessing the query before submitting it, in milliseconds', + optional: true, + }, + serviceProcessingTimeInMillis: { + type: 'number', + description: + 'Time Athena spent finalizing and publishing results after execution, in milliseconds', + optional: true, + }, + dataManifestLocation: { + type: 'string', + description: 'S3 location of the manifest file listing files written by the query', + optional: true, + }, + reusedPreviousResult: { + type: 'boolean', + description: 'Whether the query reused a previous result instead of scanning data', + optional: true, + }, + athenaError: { + type: 'json', + description: + 'Failure details for a FAILED query (errorCategory: 1 system, 2 user, 3 other; errorType; retryable; errorMessage)', + optional: true, + }, + executionParameters: { + type: 'array', + description: "Values supplied for the query's ? placeholders", + optional: true, + }, + substatementType: { + type: 'string', + description: 'Kind of query statement that was run (e.g., SELECT, INSERT, CREATE_TABLE)', + optional: true, + }, + engineVersion: { + type: 'json', + description: 'Engine version used (selectedEngineVersion, effectiveEngineVersion)', + optional: true, + }, }, } diff --git a/apps/sim/tools/athena/get_query_results.ts b/apps/sim/tools/athena/get_query_results.ts index b3ca92bd117..e6dd1d41954 100644 --- a/apps/sim/tools/athena/get_query_results.ts +++ b/apps/sim/tools/athena/get_query_results.ts @@ -84,7 +84,7 @@ export const getQueryResultsTool: InternalToolConfig< outputs: { columns: { type: 'array', - description: 'Column metadata (name and type)', + description: 'Column metadata (name, type, label, precision, scale, nullable, caseSensitive)', }, rows: { type: 'array', @@ -97,7 +97,8 @@ export const getQueryResultsTool: InternalToolConfig< }, updateCount: { type: 'number', - description: 'Number of rows affected (for INSERT/UPDATE statements)', + description: + 'Number of rows inserted or updated by a CREATE TABLE AS SELECT, INSERT INTO, or UPDATE statement', optional: true, }, }, diff --git a/apps/sim/tools/athena/start_query.ts b/apps/sim/tools/athena/start_query.ts index c6c241d4e52..7f76779e5f1 100644 --- a/apps/sim/tools/athena/start_query.ts +++ b/apps/sim/tools/athena/start_query.ts @@ -110,6 +110,33 @@ export const startQueryTool: InternalToolConfig[] nextToken: string | null updateCount: number | null @@ -159,6 +186,14 @@ interface AthenaQueryExecutionSummary { queryQueueTimeInMillis: number | null totalExecutionTimeInMillis: number | null outputLocation: string | null + servicePreProcessingTimeInMillis?: number | null + serviceProcessingTimeInMillis?: number | null + dataManifestLocation?: string | null + reusedPreviousResult?: boolean | null + athenaError?: AthenaQueryError | null + executionParameters?: string[] | null + substatementType?: string | null + engineVersion?: AthenaEngineVersion | null } interface AthenaUnprocessedQueryExecutionId { diff --git a/apps/sim/tools/azure_data_explorer/create_table.ts b/apps/sim/tools/azure_data_explorer/create_table.ts index 85f9261d8e9..2a8e2cc1c35 100644 --- a/apps/sim/tools/azure_data_explorer/create_table.ts +++ b/apps/sim/tools/azure_data_explorer/create_table.ts @@ -9,6 +9,7 @@ import { renderEntityName, transformAzureDataExplorerResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerCreateTableTool: InternalToolConfig< @@ -20,6 +21,7 @@ export const azureDataExplorerCreateTableTool: InternalToolConfig< description: 'Create a table in an Azure Data Explorer database from a CSL column schema. Succeeds without changing anything if a table of the same name already exists.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/drop_table.ts b/apps/sim/tools/azure_data_explorer/drop_table.ts index 0d42735e176..db08f1e58e0 100644 --- a/apps/sim/tools/azure_data_explorer/drop_table.ts +++ b/apps/sim/tools/azure_data_explorer/drop_table.ts @@ -7,6 +7,7 @@ import { renderEntityName, transformColumnListResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerDropTableTool: InternalToolConfig< @@ -18,6 +19,7 @@ export const azureDataExplorerDropTableTool: InternalToolConfig< description: 'Drop a table from an Azure Data Explorer database. The table becomes unavailable to queries; data recovery depends on the retention policy. Returns the tables that remain.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/ingest_from_query.ts b/apps/sim/tools/azure_data_explorer/ingest_from_query.ts index 5d59c9c95c3..f82c9be6488 100644 --- a/apps/sim/tools/azure_data_explorer/ingest_from_query.ts +++ b/apps/sim/tools/azure_data_explorer/ingest_from_query.ts @@ -9,6 +9,7 @@ import { renderIngestMode, transformAzureDataExplorerResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerIngestFromQueryTool: InternalToolConfig< @@ -20,6 +21,7 @@ export const azureDataExplorerIngestFromQueryTool: InternalToolConfig< description: "Materialize the result of a KQL query into a table with .set, .append, .set-or-append, or .set-or-replace. Use this to build rollup or summary tables instead of pushing rows from a workflow. Kusto matches the query result to the target table by column type and position, NOT by column name, so project the columns in exactly the table's order or the data lands in the wrong columns.", version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/ingest_inline.ts b/apps/sim/tools/azure_data_explorer/ingest_inline.ts index e0c69a24692..097e98a4308 100644 --- a/apps/sim/tools/azure_data_explorer/ingest_inline.ts +++ b/apps/sim/tools/azure_data_explorer/ingest_inline.ts @@ -8,6 +8,7 @@ import { renderEntityName, transformColumnListResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerIngestInlineTool: InternalToolConfig< @@ -19,6 +20,7 @@ export const azureDataExplorerIngestInlineTool: InternalToolConfig< description: 'Push rows directly into an Azure Data Explorer table with .ingest inline. Data is parsed as CSV against the table schema unless an ingestion property says otherwise. Intended for small batches — use queued or streaming ingestion for production volumes.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/list_databases.ts b/apps/sim/tools/azure_data_explorer/list_databases.ts index 2017995b98f..d674577c394 100644 --- a/apps/sim/tools/azure_data_explorer/list_databases.ts +++ b/apps/sim/tools/azure_data_explorer/list_databases.ts @@ -6,6 +6,7 @@ import { azureDataExplorerAuthInput, transformColumnListResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerListDatabasesTool: InternalToolConfig< @@ -17,6 +18,7 @@ export const azureDataExplorerListDatabasesTool: InternalToolConfig< description: 'List the databases on an Azure Data Explorer cluster that the service principal can access.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/list_functions.ts b/apps/sim/tools/azure_data_explorer/list_functions.ts index 16abd6e9152..76c5452abc8 100644 --- a/apps/sim/tools/azure_data_explorer/list_functions.ts +++ b/apps/sim/tools/azure_data_explorer/list_functions.ts @@ -6,6 +6,7 @@ import { azureDataExplorerAuthInput, transformColumnListResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerListFunctionsTool: InternalToolConfig< @@ -17,6 +18,7 @@ export const azureDataExplorerListFunctionsTool: InternalToolConfig< description: 'List the stored functions in an Azure Data Explorer database, with their parameters and bodies, so an agent can reuse existing logic instead of rewriting it.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/list_tables.ts b/apps/sim/tools/azure_data_explorer/list_tables.ts index 9c017f73262..25bba955672 100644 --- a/apps/sim/tools/azure_data_explorer/list_tables.ts +++ b/apps/sim/tools/azure_data_explorer/list_tables.ts @@ -6,6 +6,7 @@ import { azureDataExplorerAuthInput, transformColumnListResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerListTablesTool: InternalToolConfig< @@ -17,6 +18,7 @@ export const azureDataExplorerListTablesTool: InternalToolConfig< description: 'List the tables in an Azure Data Explorer database, with their folder and docstring.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/management.ts b/apps/sim/tools/azure_data_explorer/management.ts index cd7b3de74bc..989f8c07820 100644 --- a/apps/sim/tools/azure_data_explorer/management.ts +++ b/apps/sim/tools/azure_data_explorer/management.ts @@ -6,6 +6,7 @@ import { azureDataExplorerAuthInput, transformAzureDataExplorerResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerManagementTool: InternalToolConfig< @@ -17,6 +18,7 @@ export const azureDataExplorerManagementTool: InternalToolConfig< description: 'Run an Azure Data Explorer management command (a control command starting with ".") such as .show, .create, .alter, or .drop. Write commands can change data or configuration; use the Query operation for reads.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/query.ts b/apps/sim/tools/azure_data_explorer/query.ts index fb536261134..47b609c15cc 100644 --- a/apps/sim/tools/azure_data_explorer/query.ts +++ b/apps/sim/tools/azure_data_explorer/query.ts @@ -7,6 +7,7 @@ import { azureDataExplorerAuthInput, transformAzureDataExplorerResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' function parseProperties( @@ -32,8 +33,9 @@ export const azureDataExplorerQueryTool: InternalToolConfig< id: 'azure_data_explorer_query', name: 'Azure Data Explorer Query', description: - 'Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table.', + 'Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table (plus any further tables a batch or fork produces).', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', @@ -142,7 +144,44 @@ export const azureDataExplorerQueryTool: InternalToolConfig< truncated: { type: 'boolean', description: - 'Whether rows were dropped to stay within the row cap — narrow the query if true', + 'Whether rows of the primary result table were dropped to stay within the row or size cap (each additionalResults entry has its own truncated flag) — narrow the query if true', + }, + additionalResults: { + type: 'array', + description: + 'Further result tables, present only when a query batch (statements separated by ;) or fork returns more than one; the row and size caps are shared across all tables', + optional: true, + items: { + type: 'object', + properties: { + tableName: { type: 'string', description: 'Result table name', nullable: true }, + columns: { + type: 'array', + description: 'Column metadata', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried after the caps' }, + totalRowCount: { type: 'number', description: 'Rows Kusto returned' }, + truncated: { type: 'boolean', description: 'Whether rows were dropped by the caps' }, + }, + }, }, }, } diff --git a/apps/sim/tools/azure_data_explorer/show_database_schema.ts b/apps/sim/tools/azure_data_explorer/show_database_schema.ts index b47cd737c98..bc32ae33ebf 100644 --- a/apps/sim/tools/azure_data_explorer/show_database_schema.ts +++ b/apps/sim/tools/azure_data_explorer/show_database_schema.ts @@ -7,6 +7,7 @@ import { renderEntityName, transformAzureDataExplorerResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerShowDatabaseSchemaTool: InternalToolConfig< @@ -18,6 +19,7 @@ export const azureDataExplorerShowDatabaseSchemaTool: InternalToolConfig< description: 'Read the full schema of an Azure Data Explorer database as a flat list of every table and column, so an agent can discover the data model in one call.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/show_ingestion_failures.ts b/apps/sim/tools/azure_data_explorer/show_ingestion_failures.ts index b71e081bb15..119c3a31daa 100644 --- a/apps/sim/tools/azure_data_explorer/show_ingestion_failures.ts +++ b/apps/sim/tools/azure_data_explorer/show_ingestion_failures.ts @@ -7,6 +7,7 @@ import { renderOperationId, transformAzureDataExplorerResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerShowIngestionFailuresTool: InternalToolConfig< @@ -18,6 +19,7 @@ export const azureDataExplorerShowIngestionFailuresTool: InternalToolConfig< description: 'List ingestion failures recorded for a database, with the failing table, error code, root cause detail, and whether the failure is permanent or transient. Failures are retained for 14 days.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/show_operations.ts b/apps/sim/tools/azure_data_explorer/show_operations.ts index 312ecf05cae..5c6f054ccd1 100644 --- a/apps/sim/tools/azure_data_explorer/show_operations.ts +++ b/apps/sim/tools/azure_data_explorer/show_operations.ts @@ -7,6 +7,7 @@ import { renderOperationId, transformAzureDataExplorerResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerShowOperationsTool: InternalToolConfig< @@ -18,6 +19,7 @@ export const azureDataExplorerShowOperationsTool: InternalToolConfig< description: 'Check the state of administrative operations on a cluster, such as an async ingestion. Given an operation ID it returns that operation latest update; with no ID it returns the operations from the last two weeks.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/show_table_details.ts b/apps/sim/tools/azure_data_explorer/show_table_details.ts index 3333fb691f0..83ae0384682 100644 --- a/apps/sim/tools/azure_data_explorer/show_table_details.ts +++ b/apps/sim/tools/azure_data_explorer/show_table_details.ts @@ -7,6 +7,7 @@ import { renderEntityName, transformAzureDataExplorerResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerShowTableDetailsTool: InternalToolConfig< @@ -18,6 +19,7 @@ export const azureDataExplorerShowTableDetailsTool: InternalToolConfig< description: 'Read size, row count, hot-cache footprint, and effective policies for a table — or for every table in the database when no table is given. Use it to see how much data a table actually holds before querying it.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/show_table_schema.ts b/apps/sim/tools/azure_data_explorer/show_table_schema.ts index 39d33463d66..91f3c777e1f 100644 --- a/apps/sim/tools/azure_data_explorer/show_table_schema.ts +++ b/apps/sim/tools/azure_data_explorer/show_table_schema.ts @@ -7,6 +7,7 @@ import { renderEntityName, transformTableSchemaResponse, } from '@/tools/azure_data_explorer/utils' +import { AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const azureDataExplorerShowTableSchemaTool: InternalToolConfig< @@ -18,6 +19,7 @@ export const azureDataExplorerShowTableSchemaTool: InternalToolConfig< description: 'Read the column schema of an Azure Data Explorer table in CSL form (e.g., "Timestamp:datetime,Level:string"). Use this before writing a KQL query against an unfamiliar table.', version: '1.0.0', + oauth: AZURE_DATA_EXPLORER_CREDENTIAL_OAUTH, params: { clusterUri: { type: 'string', diff --git a/apps/sim/tools/azure_data_explorer/types.ts b/apps/sim/tools/azure_data_explorer/types.ts index 53fb7efa6ab..1b8be0187c2 100644 --- a/apps/sim/tools/azure_data_explorer/types.ts +++ b/apps/sim/tools/azure_data_explorer/types.ts @@ -14,8 +14,8 @@ interface AzureDataExplorerColumn { dataType: string | null } -/** Primary result table of a Kusto query or management command. */ -export interface AzureDataExplorerTable { +/** One projected Kusto result table. */ +export interface AzureDataExplorerResultTable { tableName: string | null columns: AzureDataExplorerColumn[] rows: unknown[][] @@ -27,6 +27,12 @@ export interface AzureDataExplorerTable { truncated: boolean } +/** Primary result table of a Kusto query or management command. */ +export interface AzureDataExplorerTable extends AzureDataExplorerResultTable { + /** Further QueryResult tables, present only when a batch or `fork` produced more than one. */ + additionalResults?: AzureDataExplorerResultTable[] +} + export interface AzureDataExplorerTableResponse extends ToolResponse { output: AzureDataExplorerTable } diff --git a/apps/sim/tools/azure_devops/add_comment.ts b/apps/sim/tools/azure_devops/add_comment.ts index 58a4b910613..0031a66ea6d 100644 --- a/apps/sim/tools/azure_devops/add_comment.ts +++ b/apps/sim/tools/azure_devops/add_comment.ts @@ -40,6 +40,13 @@ export const addCommentTool: ToolConfig = visibility: 'user-or-llm', description: 'Comment text (HTML supported, e.g. "

    My comment

    ")', }, + format: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comment format: "markdown" or "html". Omit to send the text without a format (API version 7.0 behavior).', + }, accessToken: { type: 'string', required: true, @@ -53,7 +60,13 @@ export const addCommentTool: ToolConfig = const url = new URL( `${azureDevOpsProjectUrl(params)}/_apis/wit/workitems/${Number(params.workItemId)}/comments` ) - url.searchParams.set('api-version', '7.0-preview.3') + const format = params.format?.trim().toLowerCase() + if (format) { + url.searchParams.set('format', format) + url.searchParams.set('api-version', '7.2-preview.4') + } else { + url.searchParams.set('api-version', '7.0-preview.3') + } return url.toString() }, method: 'POST', diff --git a/apps/sim/tools/azure_devops/azure-devops.test.ts b/apps/sim/tools/azure_devops/azure-devops.test.ts index 41a23ff4a51..6a8cb0a69c0 100644 --- a/apps/sim/tools/azure_devops/azure-devops.test.ts +++ b/apps/sim/tools/azure_devops/azure-devops.test.ts @@ -222,18 +222,25 @@ describe('Azure DevOps response transforms', () => { listPipelinesTool.transformResponse!(responseJson({ count: 0, value: [] })) ).resolves.toEqual({ success: true, - output: { content: 'No pipelines found.', metadata: { count: 0, pipelines: [] } }, + output: { + content: 'No pipelines found.', + metadata: { count: 0, pipelines: [], continuationToken: null }, + }, }) const result = await listPipelinesTool.transformResponse!( - responseJson({ - value: [{ id: 1, name: 'CI', revision: 2, url: 'https://example/p/1' }], - }) + new Response( + JSON.stringify({ + value: [{ id: 1, name: 'CI', revision: 2, url: 'https://example/p/1' }], + }), + { headers: { 'x-ms-continuationtoken': 'next-page' } } + ) ) expect(result.output.metadata).toEqual({ count: 1, pipelines: [{ id: 1, name: 'CI', folder: '\\', revision: 2, url: 'https://example/p/1' }], + continuationToken: 'next-page', }) }) @@ -331,7 +338,7 @@ describe('Azure DevOps response transforms', () => { ]) const getWorkItem = await getWorkItemTool.transformResponse!(responseJson(rawWorkItem)) - expect(getWorkItem.output.metadata.workItem).toEqual({ + expect(getWorkItem.output.metadata.workItem).toMatchObject({ id: 101, title: 'SimIntegrationTest Issue', state: 'Doing', @@ -417,6 +424,39 @@ describe('Azure DevOps response transforms', () => { ).rejects.toThrow(/Failed to hydrate work item details/) }) + it('hydrates the work items of a WIQL link query', async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(responseJson({ value: [rawWorkItem] }))) + vi.stubGlobal('fetch', fetchMock) + + const transform = queryWorkItemsTool.transformResponse as NonNullable< + typeof queryWorkItemsTool.transformResponse + > + const result = await transform( + responseJson({ + queryResultType: 'workItemLink', + workItemRelations: [ + { rel: null, source: null, target: { id: 101 } }, + { rel: 'System.LinkTypes.Hierarchy-Forward', source: { id: 101 }, target: { id: 102 } }, + ], + }), + { + ...baseParams, + wiqlQuery: 'SELECT [System.Id] FROM WorkItemLinks', + } satisfies QueryWorkItemsParams + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const chunk = new URL(String(fetchMock.mock.calls[0][0])) + expect(chunk.searchParams.get('ids')).toBe('101,102') + expect(result.output.metadata.totalMatched).toBe(2) + expect(result.output.metadata.workItemRelations).toEqual([ + { rel: null, sourceId: null, targetId: 101 }, + { rel: 'System.LinkTypes.Hierarchy-Forward', sourceId: 101, targetId: 102 }, + ]) + }) + it('does not hydrate WIQL empty results', async () => { const fetchMock = vi.fn() globalThis.fetch = fetchMock as unknown as typeof fetch diff --git a/apps/sim/tools/azure_devops/create_work_item.ts b/apps/sim/tools/azure_devops/create_work_item.ts index 74ad9241e77..113c64fcf1c 100644 --- a/apps/sim/tools/azure_devops/create_work_item.ts +++ b/apps/sim/tools/azure_devops/create_work_item.ts @@ -10,6 +10,8 @@ import { azureDevOpsProjectUrl, formatWorkItem, mapWorkItem, + WORK_ITEM_LIST_DETAIL_OUTPUT_PROPERTIES, + WORK_ITEM_RAW_DETAIL_OUTPUT_PROPERTIES, } from '@/tools/azure_devops/utils' import { AZURE_DEVOPS_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -242,6 +244,8 @@ export const createWorkItemTool: ToolConfig | null }) => ({ id: r.id, name: r.name, @@ -75,6 +78,13 @@ export const getBuildTimelineTool: ToolConfig ({ + type: issue.type ?? '', + category: issue.category ?? null, + message: issue.message ?? '', + })), }) ) @@ -92,7 +102,11 @@ export const getBuildTimelineTool: ToolConfig - `[${r.type}] ${r.name} — result: ${r.result}, logId: ${r.logId ?? 'none'}, errors: ${r.errorCount}` + `[${r.type}] ${r.name} — result: ${r.result}, logId: ${r.logId ?? 'none'}, errors: ${r.errorCount}` + + r.issues + .filter((issue) => issue.type === 'error') + .map((issue) => `\n error: ${issue.message}`) + .join('') ) .join('\n') @@ -139,6 +153,32 @@ export const getBuildTimelineTool: ToolConfig }, areaPath: { type: 'string', description: 'Area path of the work item' }, url: { type: 'string', description: 'API URL for the work item' }, + ...WORK_ITEM_LIST_DETAIL_OUTPUT_PROPERTIES, + ...WORK_ITEM_RAW_DETAIL_OUTPUT_PROPERTIES, }, }, }, diff --git a/apps/sim/tools/azure_devops/get_work_items_batch.ts b/apps/sim/tools/azure_devops/get_work_items_batch.ts index 60964f76a23..03c2c4229b8 100644 --- a/apps/sim/tools/azure_devops/get_work_items_batch.ts +++ b/apps/sim/tools/azure_devops/get_work_items_batch.ts @@ -4,7 +4,14 @@ import type { GetWorkItemsBatchResponse, } from '@/tools/azure_devops/types' import type { AzureDevOpsRawWorkItem } from '@/tools/azure_devops/utils' -import { azureDevOpsProjectUrl, formatWorkItem, mapWorkItem } from '@/tools/azure_devops/utils' +import { + azureDevOpsProjectUrl, + fetchWorkItemsChunk, + formatWorkItem, + mapWorkItem, + WORK_ITEM_LIST_DETAIL_OUTPUT_PROPERTIES, + WORK_ITEMS_BATCH_SIZE, +} from '@/tools/azure_devops/utils' import { AZURE_DEVOPS_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -68,48 +75,31 @@ export const getWorkItemsBatchTool: ToolConfig { + transformResponse: async (response, params, context) => { const firstData = await response.json() const workItems: AzureDevOpsWorkItem[] = (firstData.value ?? []).map( - (raw: AzureDevOpsRawWorkItem) => mapWorkItem(raw) + (raw: AzureDevOpsRawWorkItem) => mapWorkItem(raw, { includeRawDetails: false }) ) - const allIds = params!.ids + const allIds = (params?.ids ?? '') .split(',') .map((id) => id.trim()) .filter(Boolean) - if (allIds.length > 200) { - const BATCH_SIZE = 200 - const organization = params!.organization.trim() - const project = params!.project.trim() - const authHeader = `Basic ${btoa(`:${params!.accessToken}`)}` - - for (let i = BATCH_SIZE; i < allIds.length; i += BATCH_SIZE) { - const chunk = allIds.slice(i, i + BATCH_SIZE) - const detailsUrl = new URL( - `${azureDevOpsProjectUrl({ organization, project })}/_apis/wit/workitems` + if (allIds.length > WORK_ITEMS_BATCH_SIZE) { + if (!params) { + throw new Error('Get Work Items Batch requires organization, project, and access token.') + } + for (let i = WORK_ITEMS_BATCH_SIZE; i < allIds.length; i += WORK_ITEMS_BATCH_SIZE) { + const chunk = allIds.slice(i, i + WORK_ITEMS_BATCH_SIZE) + workItems.push( + ...(await fetchWorkItemsChunk( + params, + chunk, + 'Failed to fetch work item batch chunk', + context?.signal + )) ) - detailsUrl.searchParams.set('ids', chunk.join(',')) - detailsUrl.searchParams.set('$expand', 'all') - detailsUrl.searchParams.set('api-version', '7.2-preview.3') - - const chunkResponse = await fetch(detailsUrl.toString(), { - method: 'GET', - headers: { 'Content-Type': 'application/json', Authorization: authHeader }, - }) - - if (!chunkResponse.ok) { - const errorBody = await chunkResponse.text().catch(() => '') - throw new Error( - `Failed to fetch work item batch chunk (${chunkResponse.status}): ${errorBody || chunkResponse.statusText}` - ) - } - - const chunkData = await chunkResponse.json() - for (const raw of chunkData.value ?? []) { - workItems.push(mapWorkItem(raw as AzureDevOpsRawWorkItem)) - } } } @@ -164,6 +154,7 @@ export const getWorkItemsBatchTool: ToolConfig = id: 'azure_devops_list_builds', name: 'Azure DevOps List Builds', description: - 'List builds in an Azure DevOps project. Optionally filter by pipeline definition, status, result, or branch.', + 'List builds in an Azure DevOps project. Optionally filter by pipeline definition, status, result, reason, requester, branch, or time range, and page with a continuation token.', version: '1.0.0', oauth: AZURE_DEVOPS_CREDENTIAL_OAUTH, @@ -55,6 +55,46 @@ export const listBuildsTool: ToolConfig = visibility: 'user-or-llm', description: 'Filter by source branch name (e.g. "refs/heads/main")', }, + reasonFilter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter by build reason: manual, individualCI, batchedCI, schedule, pullRequest, buildCompletion, resourceTrigger', + }, + requestedFor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter to builds requested for this user (email or ID)', + }, + minTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ISO 8601 date-time; only builds that finished/started/queued after it (per queryOrder)', + }, + maxTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ISO 8601 date-time; only builds that finished/started/queued before it (per queryOrder)', + }, + queryOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Sort order: queueTimeDescending, queueTimeAscending, startTimeDescending, startTimeAscending, finishTimeDescending, finishTimeAscending', + }, + continuationToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Continuation token from a previous List Builds call, to fetch the next page', + }, accessToken: { type: 'string', required: true, @@ -72,6 +112,13 @@ export const listBuildsTool: ToolConfig = if (params.statusFilter) url.searchParams.set('statusFilter', params.statusFilter) if (params.resultFilter) url.searchParams.set('resultFilter', params.resultFilter) if (params.branchName) url.searchParams.set('branchName', params.branchName) + if (params.reasonFilter) url.searchParams.set('reasonFilter', params.reasonFilter) + if (params.requestedFor) url.searchParams.set('requestedFor', params.requestedFor) + if (params.minTime) url.searchParams.set('minTime', params.minTime) + if (params.maxTime) url.searchParams.set('maxTime', params.maxTime) + if (params.queryOrder) url.searchParams.set('queryOrder', params.queryOrder) + if (params.continuationToken) + url.searchParams.set('continuationToken', params.continuationToken) return url.toString() }, method: 'GET', @@ -96,6 +143,9 @@ export const listBuildsTool: ToolConfig = sourceVersion: b.sourceVersion, definition: { id: b.definition?.id ?? 0, name: b.definition?.name ?? '' }, webUrl: b._links?.web?.href ?? '', + reason: b.reason ?? null, + requestedFor: b.requestedFor?.displayName ?? null, + tags: b.tags ?? [], })) const content = @@ -119,6 +169,7 @@ export const listBuildsTool: ToolConfig = metadata: { count: data.count ?? builds.length, builds, + continuationToken: response.headers.get('x-ms-continuationtoken'), }, }, } @@ -131,6 +182,13 @@ export const listBuildsTool: ToolConfig = description: 'Builds metadata', properties: { count: { type: 'number', description: 'Total number of builds returned' }, + continuationToken: { + type: 'string', + description: + 'Token for the next page (from the x-ms-continuationtoken header); pass it back as continuationToken. Null on the last page.', + optional: true, + nullable: true, + }, builds: { type: 'array', description: 'Array of build objects', @@ -167,6 +225,24 @@ export const listBuildsTool: ToolConfig = }, }, webUrl: { type: 'string', description: 'Browser URL for the build' }, + reason: { + type: 'string', + description: 'Why the build was created (e.g. manual, individualCI, pullRequest)', + optional: true, + nullable: true, + }, + requestedFor: { + type: 'string', + description: 'Display name of the user the build was queued for', + optional: true, + nullable: true, + }, + tags: { + type: 'array', + description: 'Build tags', + optional: true, + items: { type: 'string' }, + }, }, }, }, @@ -187,6 +263,9 @@ interface AzureDevOpsBuildItem { sourceVersion: string definition: { id: number; name: string } webUrl: string + reason: string | null + requestedFor: string | null + tags: string[] } interface AzureDevOpsRawBuild { @@ -201,4 +280,7 @@ interface AzureDevOpsRawBuild { sourceVersion: string definition?: { id: number; name?: string } _links?: { web?: { href: string } } + reason?: string + requestedFor?: { displayName?: string } + tags?: string[] } diff --git a/apps/sim/tools/azure_devops/list_pipelines.ts b/apps/sim/tools/azure_devops/list_pipelines.ts index 8c41dd9e870..de40e17a405 100644 --- a/apps/sim/tools/azure_devops/list_pipelines.ts +++ b/apps/sim/tools/azure_devops/list_pipelines.ts @@ -28,7 +28,7 @@ export const listPipelinesTool: ToolConfig `${azureDevOpsProjectUrl(params)}/_apis/wit/wiql?api-version=7.2-preview.2`, + url: (params) => { + const url = new URL(`${azureDevOpsProjectUrl(params)}/_apis/wit/wiql`) + if (params.top) url.searchParams.set('$top', Number(params.top).toString()) + url.searchParams.set('api-version', '7.2-preview.2') + return url.toString() + }, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', @@ -54,55 +72,46 @@ export const queryWorkItemsTool: ToolConfig ({ query: params.wiqlQuery }), }, - transformResponse: async (response, params) => { - const wiqlData = await response.json() - const workItemRefs: Array<{ id: number; url: string }> = wiqlData.workItems ?? [] + transformResponse: async (response, params, context) => { + const wiqlData: AzureDevOpsRawWiqlResult = await response.json() + const links: AzureDevOpsWorkItemLink[] = (wiqlData.workItemRelations ?? []).map((link) => ({ + rel: link.rel ?? null, + sourceId: link.source?.id ?? null, + targetId: link.target?.id ?? null, + })) + const linkIds = links.flatMap((link) => + [link.sourceId, link.targetId].filter((id): id is number => typeof id === 'number') + ) + const allIds = Array.from( + new Set([...(wiqlData.workItems ?? []).map((wi) => wi.id), ...linkIds]) + ) + const linkMetadata = wiqlData.workItemRelations ? { workItemRelations: links } : {} - if (workItemRefs.length === 0) { + if (allIds.length === 0) { return { success: true, output: { content: 'No work items matched the query.', - metadata: { count: 0, workItems: [] }, + metadata: { count: 0, workItems: [], ...linkMetadata }, }, } } - const allIds = workItemRefs.map((wi) => wi.id) - const BATCH_SIZE = 200 - const organization = params!.organization.trim() - const project = params!.project.trim() - const authHeader = `Basic ${btoa(`:${params!.accessToken}`)}` + if (!params) { + throw new Error('Query Work Items requires organization, project, and access token.') + } const workItems: AzureDevOpsWorkItem[] = [] - for (let i = 0; i < allIds.length; i += BATCH_SIZE) { - const chunk = allIds.slice(i, i + BATCH_SIZE) - const detailsUrl = new URL( - `${azureDevOpsProjectUrl({ organization, project })}/_apis/wit/workitems` + for (let i = 0; i < allIds.length; i += WORK_ITEMS_BATCH_SIZE) { + const chunk = allIds.slice(i, i + WORK_ITEMS_BATCH_SIZE) + workItems.push( + ...(await fetchWorkItemsChunk( + params, + chunk, + 'Failed to hydrate work item details', + context?.signal + )) ) - detailsUrl.searchParams.set('ids', chunk.join(',')) - detailsUrl.searchParams.set('$expand', 'all') - detailsUrl.searchParams.set('api-version', '7.2-preview.3') - - const detailsResponse = await fetch(detailsUrl.toString(), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: authHeader, - }, - }) - - if (!detailsResponse.ok) { - const errorBody = await detailsResponse.text().catch(() => '') - throw new Error( - `Failed to hydrate work item details (${detailsResponse.status}): ${errorBody || detailsResponse.statusText}` - ) - } - - const detailsData = await detailsResponse.json() - for (const raw of detailsData.value ?? []) { - workItems.push(mapWorkItem(raw as AzureDevOpsRawWorkItem)) - } } const content = @@ -114,7 +123,12 @@ export const queryWorkItemsTool: ToolConfig + workItemRelations?: Array<{ + rel?: string | null + source?: { id: number } | null + target?: { id: number } | null + }> +} diff --git a/apps/sim/tools/azure_devops/types.ts b/apps/sim/tools/azure_devops/types.ts index 0ea8f0c2305..31a345b4d44 100644 --- a/apps/sim/tools/azure_devops/types.ts +++ b/apps/sim/tools/azure_devops/types.ts @@ -31,6 +31,7 @@ export interface ListPipelinesResponse extends ToolResponse { metadata: { count: number pipelines: AzureDevOpsPipeline[] + continuationToken?: string | null } } } @@ -129,6 +130,12 @@ export interface ListBuildsParams extends AzureDevOpsBaseParams { statusFilter?: string resultFilter?: string branchName?: string + reasonFilter?: string + requestedFor?: string + minTime?: string + maxTime?: string + queryOrder?: string + continuationToken?: string } interface AzureDevOpsBuild { @@ -146,6 +153,9 @@ interface AzureDevOpsBuild { name: string } webUrl: string + reason?: string | null + requestedFor?: string | null + tags?: string[] } export interface ListBuildsResponse extends ToolResponse { @@ -154,6 +164,7 @@ export interface ListBuildsResponse extends ToolResponse { metadata: { count: number builds: AzureDevOpsBuild[] + continuationToken?: string | null } } } @@ -217,6 +228,9 @@ export interface AzureDevOpsBuildTimelineRecord { warningCount: number startTime: string finishTime: string + parentId?: string | null + state?: string | null + issues: Array<{ type: string; category: string | null; message: string }> } export interface GetBuildTimelineResponse extends ToolResponse { @@ -257,6 +271,13 @@ export interface GetWorkItemsBetweenBuildsResponse extends ToolResponse { export interface QueryWorkItemsParams extends AzureDevOpsBaseParams { wiqlQuery: string + top?: number +} + +export interface AzureDevOpsWorkItemLink { + rel: string | null + sourceId: number | null + targetId: number | null } export interface AzureDevOpsWorkItem { @@ -267,6 +288,26 @@ export interface AzureDevOpsWorkItem { assignedTo: string | null areaPath: string url: string + rev?: number | null + description?: string | null + iterationPath?: string | null + reason?: string | null + tags?: string | null + priority?: number | null + assignedToUniqueName?: string | null + createdBy?: string | null + createdDate?: string | null + changedBy?: string | null + changedDate?: string | null + webUrl?: string | null + relations?: AzureDevOpsWorkItemRelation[] | null + fields?: Record +} + +interface AzureDevOpsWorkItemRelation { + rel: string + url: string + attributes: Record } export interface QueryWorkItemsResponse extends ToolResponse { @@ -276,6 +317,7 @@ export interface QueryWorkItemsResponse extends ToolResponse { count: number totalMatched?: number workItems: AzureDevOpsWorkItem[] + workItemRelations?: AzureDevOpsWorkItemLink[] } } } @@ -369,6 +411,7 @@ export interface UpdateWorkItemParams extends AzureDevOpsBaseParams { /** Microsoft.VSTS.Scheduling.CompletedWork — Task only in the Basic process. */ completedWork?: number areaPath?: string + iterationPath?: string state?: string tags?: string } @@ -387,6 +430,7 @@ export interface UpdateWorkItemResponse extends ToolResponse { export interface AddCommentParams extends AzureDevOpsBaseParams { workItemId: number text: string + format?: string } export interface AzureDevOpsComment { diff --git a/apps/sim/tools/azure_devops/update_work_item.ts b/apps/sim/tools/azure_devops/update_work_item.ts index 62a11ce2671..ab550c8aaae 100644 --- a/apps/sim/tools/azure_devops/update_work_item.ts +++ b/apps/sim/tools/azure_devops/update_work_item.ts @@ -10,6 +10,8 @@ import { azureDevOpsProjectUrl, formatWorkItem, mapWorkItem, + WORK_ITEM_LIST_DETAIL_OUTPUT_PROPERTIES, + WORK_ITEM_RAW_DETAIL_OUTPUT_PROPERTIES, } from '@/tools/azure_devops/utils' import { AZURE_DEVOPS_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -72,6 +74,12 @@ export const updateWorkItemTool: ToolConfig, + errorPrefix: string, + signal?: AbortSignal +): Promise { + const url = new URL(`${azureDevOpsProjectUrl(params)}/_apis/wit/workitems`) + url.searchParams.set('ids', ids.join(',')) + url.searchParams.set('$expand', 'all') + url.searchParams.set('api-version', '7.2-preview.3') + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${btoa(`:${params.accessToken}`)}`, + }, + signal, + }) + + if (!response.ok) { + const errorBody = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Azure DevOps work items error body', + signal, + }).catch(() => '') + throw new Error(`${errorPrefix} (${response.status}): ${errorBody || response.statusText}`) + } + + const data = await readResponseJsonWithLimit<{ value?: AzureDevOpsRawWorkItem[] }>(response, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + label: 'Azure DevOps work items response', + signal, + }) + return (data.value ?? []).map((raw: AzureDevOpsRawWorkItem) => + mapWorkItem(raw, { includeRawDetails: false }) + ) +} + /** States for Azure DevOps Basic process work items (Issue, Task, Epic). */ export const AZURE_DEVOPS_BASIC_WORK_ITEM_STATES = ['To Do', 'Doing', 'Done'] as const @@ -63,20 +118,161 @@ export function appendEffortPatchOp( appendFieldPatchOp(ops, 'Microsoft.VSTS.Scheduling.Effort', effort, patchOp, 'number') } -export function mapWorkItem(raw: AzureDevOpsRawWorkItem): AzureDevOpsWorkItem { +/** + * Maps a raw work item to the tool output. Multi-item hydration (query, batch) passes + * `includeRawDetails: false` to keep the per-item payload to the summary and detail fields, + * leaving the full field bag and relations to single-item reads. + */ +export function mapWorkItem( + raw: AzureDevOpsRawWorkItem, + { includeRawDetails = true }: { includeRawDetails?: boolean } = {} +): AzureDevOpsWorkItem { const fields = raw.fields ?? {} + const assignedTo = identityField(fields['System.AssignedTo']) + const priority = fields['Microsoft.VSTS.Common.Priority'] return { id: raw.id, title: (fields['System.Title'] as string | undefined) ?? '', state: (fields['System.State'] as string | undefined) ?? '', workItemType: (fields['System.WorkItemType'] as string | undefined) ?? '', - assignedTo: - (fields['System.AssignedTo'] as { displayName?: string } | undefined)?.displayName ?? null, + assignedTo: assignedTo?.displayName ?? null, areaPath: (fields['System.AreaPath'] as string | undefined) ?? '', url: raw.url, + rev: raw.rev ?? null, + description: toStringOrNull(fields['System.Description']), + iterationPath: toStringOrNull(fields['System.IterationPath']), + reason: toStringOrNull(fields['System.Reason']), + tags: toStringOrNull(fields['System.Tags']), + priority: typeof priority === 'number' ? priority : null, + assignedToUniqueName: assignedTo?.uniqueName ?? null, + createdBy: identityField(fields['System.CreatedBy'])?.displayName ?? null, + createdDate: toStringOrNull(fields['System.CreatedDate']), + changedBy: identityField(fields['System.ChangedBy'])?.displayName ?? null, + changedDate: toStringOrNull(fields['System.ChangedDate']), + webUrl: raw._links?.html?.href ?? null, + ...(includeRawDetails && { + relations: raw.relations + ? raw.relations.map((relation) => ({ + rel: relation.rel, + url: relation.url, + attributes: relation.attributes ?? {}, + })) + : null, + fields, + }), + } +} + +function identityField(value: unknown): AzureDevOpsIdentityRef | null { + const record = toRecordOrNull(value) + if (!record) return null + return { + displayName: toStringOrNull(record.displayName) ?? undefined, + uniqueName: toStringOrNull(record.uniqueName) ?? undefined, } } +/** Output schema for the work item fields beyond the original summary set, on every read. */ +export const WORK_ITEM_LIST_DETAIL_OUTPUT_PROPERTIES: Record = { + rev: { + type: 'number', + description: 'Revision number of the work item', + optional: true, + nullable: true, + }, + description: { + type: 'string', + description: 'Description (System.Description, HTML)', + optional: true, + nullable: true, + }, + iterationPath: { + type: 'string', + description: 'Iteration path (System.IterationPath)', + optional: true, + nullable: true, + }, + reason: { + type: 'string', + description: 'Reason for the current state (System.Reason)', + optional: true, + nullable: true, + }, + tags: { + type: 'string', + description: 'Semicolon-separated tags (System.Tags)', + optional: true, + nullable: true, + }, + priority: { + type: 'number', + description: 'Priority (Microsoft.VSTS.Common.Priority)', + optional: true, + nullable: true, + }, + assignedToUniqueName: { + type: 'string', + description: 'Unique name (usually the email) of the assigned user', + optional: true, + nullable: true, + }, + createdBy: { + type: 'string', + description: 'Display name of the creator', + optional: true, + nullable: true, + }, + createdDate: { + type: 'string', + description: 'ISO 8601 creation timestamp', + optional: true, + nullable: true, + }, + changedBy: { + type: 'string', + description: 'Display name of the last user to change the work item', + optional: true, + nullable: true, + }, + changedDate: { + type: 'string', + description: 'ISO 8601 last-changed timestamp', + optional: true, + nullable: true, + }, + webUrl: { + type: 'string', + description: 'Browser URL for the work item', + optional: true, + nullable: true, + }, +} + +/** Output schema for the raw field bag and relations, returned by single-item reads only. */ +export const WORK_ITEM_RAW_DETAIL_OUTPUT_PROPERTIES: Record = { + relations: { + type: 'array', + description: + 'Links to other work items, attachments, and artifacts (rel such as System.LinkTypes.Hierarchy-Forward or AttachedFile, url, attributes); null when the response did not include relations (Create and Update do not expand them)', + optional: true, + nullable: true, + items: { + type: 'object', + properties: { + rel: { type: 'string', description: 'Relation type' }, + url: { type: 'string', description: 'URL of the linked resource' }, + attributes: { type: 'json', description: 'Link attributes (name, comment, isLocked)' }, + }, + }, + }, + fields: { + type: 'json', + description: + 'Every field returned for the work item, keyed by reference name (includes custom and process-specific fields)', + optional: true, + }, +} + export function formatWorkItem(w: AzureDevOpsWorkItem): string { return [ `ID: ${w.id} [${w.workItemType}] ${w.title}`, @@ -88,8 +284,11 @@ export function formatWorkItem(w: AzureDevOpsWorkItem): string { export interface AzureDevOpsRawWorkItem { id: number + rev?: number url: string fields: Record + relations?: Array<{ rel: string; url: string; attributes?: Record }> + _links?: { html?: { href?: string } } } export function mapComment(raw: AzureDevOpsRawComment): AzureDevOpsComment { @@ -119,6 +318,7 @@ export function formatComment(comment: AzureDevOpsComment): string { interface AzureDevOpsIdentityRef { displayName?: string + uniqueName?: string } export interface AzureDevOpsRawComment { diff --git a/apps/sim/tools/binary-downloads.test.ts b/apps/sim/tools/binary-downloads.test.ts index f05fdd1d928..7bd3e310378 100644 --- a/apps/sim/tools/binary-downloads.test.ts +++ b/apps/sim/tools/binary-downloads.test.ts @@ -64,6 +64,11 @@ const DOWNLOAD_CASES: DownloadCase[] = [ size, name: 'download.pdf', lastModified: 'Fri, 11 Sep 2026 12:00:00 GMT', + etag: null, + versionId: null, + storageClass: null, + serverSideEncryption: null, + userMetadata: {}, }) expect(output.url).toMatch( /^https:\/\/test-bucket\.s3\.us-east-1\.amazonaws\.com\/folder\/download\.pdf\?/ diff --git a/apps/sim/tools/bitbucket/list_commits.ts b/apps/sim/tools/bitbucket/list_commits.ts index a1536e2c2fb..6e0d59383d1 100644 --- a/apps/sim/tools/bitbucket/list_commits.ts +++ b/apps/sim/tools/bitbucket/list_commits.ts @@ -18,6 +18,7 @@ import { normalizeBitbucketCommit, normalizeBitbucketPage, } from '@/tools/bitbucket/utils' +import { optionalBitbucketString, optionalBitbucketStringArray } from '@/tools/bitbucket/validation' import type { ToolConfig } from '@/tools/types' export const bitbucketListCommitsTool: ToolConfig< @@ -29,13 +30,50 @@ export const bitbucketListCommitsTool: ToolConfig< description: 'List repository commits in reverse chronological order', version: '1.0.0', oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository'] }, - params: { ...BITBUCKET_REPOSITORY_PARAMS, ...BITBUCKET_PAGINATION_PARAMS }, + params: { + ...BITBUCKET_REPOSITORY_PARAMS, + include: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Branches, tags, or commit hashes whose history to include; omit to list commits on every ref', + items: { type: 'string' }, + }, + exclude: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Branches, tags, or commit hashes whose reachable commits to leave out', + items: { type: 'string' }, + }, + path: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only list commits that changed this file or directory', + }, + ...BITBUCKET_PAGINATION_PARAMS, + }, request: { - url: (params) => - bitbucketApiUrl(`${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/commits`, { - nextUrl: params.nextUrl, - pageLen: params.pageLen, - }), + url: (params) => { + const include = optionalBitbucketStringArray(params.include, 'include', 'include ref') + const exclude = optionalBitbucketStringArray(params.exclude, 'exclude', 'exclude ref') + const path = optionalBitbucketString(params.path, 'path') + const url = bitbucketApiUrl( + `${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/commits`, + { + nextUrl: params.nextUrl, + pageLen: params.pageLen, + query: { path }, + } + ) + if (params.nextUrl !== undefined) return url + const withRefs = new URL(url) + for (const ref of include ?? []) withRefs.searchParams.append('include', ref) + for (const ref of exclude ?? []) withRefs.searchParams.append('exclude', ref) + return withRefs.toString() + }, method: 'GET', headers: (params) => bitbucketHeaders(params.accessToken), retry: BITBUCKET_READ_RETRY, diff --git a/apps/sim/tools/bitbucket/types.ts b/apps/sim/tools/bitbucket/types.ts index 583029a7304..7e90c30a1ea 100644 --- a/apps/sim/tools/bitbucket/types.ts +++ b/apps/sim/tools/bitbucket/types.ts @@ -52,7 +52,11 @@ export interface BitbucketDeleteBranchParams extends BitbucketRepositoryParams { export interface BitbucketListCommitsParams extends BitbucketRepositoryParams, - BitbucketPaginationParams {} + BitbucketPaginationParams { + include?: string[] + exclude?: string[] + path?: string +} export interface BitbucketGetCommitParams extends BitbucketRepositoryParams { commit: string @@ -288,6 +292,8 @@ export interface BitbucketPullRequestEndpoint { commitHash: string | null repositoryUuid: string | null repositoryFullName: string | null + mergeStrategies: string[] | null + defaultMergeStrategy: string | null } export interface BitbucketParticipant { @@ -615,6 +621,17 @@ export const BITBUCKET_PR_ENDPOINT_OUTPUT_PROPERTIES: Record = { diff --git a/apps/sim/tools/bitbucket/utils.ts b/apps/sim/tools/bitbucket/utils.ts index c2f02c4273c..a7bc52d3459 100644 --- a/apps/sim/tools/bitbucket/utils.ts +++ b/apps/sim/tools/bitbucket/utils.ts @@ -684,11 +684,17 @@ export function normalizeBitbucketFileMetadata(value: unknown): BitbucketFileMet function normalizePullRequestEndpoint(value: unknown): BitbucketPullRequestEndpoint | null { const data = toRecordOrNull(value) if (!data) return null + const branch = readRecord(data, 'branch') + const mergeStrategies = branch?.merge_strategies return { - branchName: readString(readRecord(data, 'branch'), 'name'), + branchName: readString(branch, 'name'), commitHash: readString(readRecord(data, 'commit'), 'hash'), repositoryUuid: readString(readRecord(data, 'repository'), 'uuid'), repositoryFullName: readString(readRecord(data, 'repository'), 'full_name'), + mergeStrategies: Array.isArray(mergeStrategies) + ? mergeStrategies.filter((strategy): strategy is string => typeof strategy === 'string') + : null, + defaultMergeStrategy: readString(branch, 'default_merge_strategy'), } } diff --git a/apps/sim/tools/cbinsights/chat.ts b/apps/sim/tools/cbinsights/chat.ts index bf386532997..52cc1498152 100644 --- a/apps/sim/tools/cbinsights/chat.ts +++ b/apps/sim/tools/cbinsights/chat.ts @@ -1,5 +1,6 @@ import type { CbInsightsAuthParams, CbInsightsChatResponse } from '@/tools/cbinsights/types' import { createInternalToolOperationInput } from '@/tools/operation-input' +import { CBINSIGHTS_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export interface CbInsightsChatParams extends CbInsightsAuthParams { @@ -14,6 +15,7 @@ export const cbinsightsChatTool: InternalToolConfig = { @@ -8,6 +9,7 @@ export const cbinsightsGetOrgOutlookTool: InternalToolConfig = { @@ -8,6 +9,7 @@ export const cbinsightsGetOrgRevenueTool: InternalToolConfig = { @@ -8,6 +9,7 @@ export const cbinsightsGetStrategyMapTool: InternalToolConfig) { + const body = getMetricStatisticsTool.operation.input(params as never) + return awsCloudwatchGetMetricStatisticsContract.body?.safeParse(body) +} + +describe('cloudwatch_get_metric_statistics', () => { + it('accepts percentile statistics without a standard statistic', () => { + const params = { ...PARAMS, extendedStatistics: ['p99', 'p95'] } + expect(() => + validateRequiredParametersAfterMerge( + 'cloudwatch_get_metric_statistics', + getMetricStatisticsTool, + params + ) + ).not.toThrow() + expect(parseRequest(params)?.success).toBe(true) + }) + + it('rejects a request with neither standard nor percentile statistics', () => { + const result = parseRequest(PARAMS) + expect(result?.success).toBe(false) + expect(result?.error?.issues.map((issue) => issue.message)).toEqual([ + 'Provide statistics or extendedStatistics', + ]) + }) +}) diff --git a/apps/sim/tools/cloudwatch/get_metric_statistics.ts b/apps/sim/tools/cloudwatch/get_metric_statistics.ts index 9e8c397860b..dafbc39844b 100644 --- a/apps/sim/tools/cloudwatch/get_metric_statistics.ts +++ b/apps/sim/tools/cloudwatch/get_metric_statistics.ts @@ -66,9 +66,10 @@ export const getMetricStatisticsTool: InternalToolConfig< }, statistics: { type: 'array', - required: true, + required: false, visibility: 'user-or-llm', - description: 'Statistics to retrieve (Average, Sum, Minimum, Maximum, SampleCount)', + description: + 'Statistics to retrieve (Average, Sum, Minimum, Maximum, SampleCount). Required unless extendedStatistics is set, which takes precedence', }, dimensions: { type: 'string', @@ -76,6 +77,20 @@ export const getMetricStatisticsTool: InternalToolConfig< visibility: 'user-or-llm', description: 'Dimensions as JSON (e.g., {"InstanceId": "i-1234"})', }, + extendedStatistics: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Percentile statistics to retrieve (e.g., ["p99", "p95"]). AWS accepts either Statistics or ExtendedStatistics, so when set these are requested instead of statistics', + }, + unit: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Only return data reported with this unit (e.g., Percent, Milliseconds). Omit to return all units', + }, }, operation: { @@ -88,8 +103,13 @@ export const getMetricStatisticsTool: InternalToolConfig< startTime: params.startTime, endTime: params.endTime, period: params.period, - statistics: params.statistics, + ...(params.statistics && params.statistics.length > 0 && { statistics: params.statistics }), ...(params.dimensions && { dimensions: params.dimensions }), + ...(params.extendedStatistics && + params.extendedStatistics.length > 0 && { + extendedStatistics: params.extendedStatistics, + }), + ...(params.unit && { unit: params.unit }), }), }, @@ -124,6 +144,10 @@ export const getMetricStatisticsTool: InternalToolConfig< maximum: { type: 'number', description: 'Maximum statistic value' }, sampleCount: { type: 'number', description: 'Sample count statistic value' }, unit: { type: 'string', description: 'Unit of the metric' }, + extendedStatistics: { + type: 'json', + description: 'Percentile values keyed by statistic (e.g., {"p99": 120.5})', + }, }, }, }, diff --git a/apps/sim/tools/cloudwatch/query_logs.ts b/apps/sim/tools/cloudwatch/query_logs.ts index d18c3daf998..6f956a1eef8 100644 --- a/apps/sim/tools/cloudwatch/query_logs.ts +++ b/apps/sim/tools/cloudwatch/query_logs.ts @@ -92,6 +92,7 @@ export const queryLogsTool: InternalToolConfig< results: data.output.results, statistics: data.output.statistics, status: data.output.status, + queryId: data.output.queryId ?? null, }, } }, @@ -117,5 +118,10 @@ export const queryLogsTool: InternalToolConfig< type: 'string', description: 'Query completion status (Complete, Failed, Cancelled, or Timeout)', }, + queryId: { + type: 'string', + description: 'ID of the Logs Insights query', + optional: true, + }, }, } diff --git a/apps/sim/tools/cloudwatch/types.ts b/apps/sim/tools/cloudwatch/types.ts index 9efd98f8de3..b7deac4d501 100644 --- a/apps/sim/tools/cloudwatch/types.ts +++ b/apps/sim/tools/cloudwatch/types.ts @@ -25,6 +25,7 @@ export interface CloudWatchGetLogEventsParams extends CloudWatchConnectionConfig startTime?: number endTime?: number limit?: number + nextToken?: string } export interface CloudWatchQueryLogsResponse extends ToolResponse { @@ -36,6 +37,7 @@ export interface CloudWatchQueryLogsResponse extends ToolResponse { recordsScanned: number } status: string + queryId?: string | null } } @@ -58,6 +60,8 @@ export interface CloudWatchGetLogEventsResponse extends ToolResponse { message: string | undefined ingestionTime: number | undefined }[] + nextForwardToken?: string | null + nextBackwardToken?: string | null } } @@ -102,8 +106,10 @@ export interface CloudWatchGetMetricStatisticsParams extends CloudWatchConnectio startTime: number endTime: number period: number - statistics: string[] + statistics?: string[] dimensions?: string + extendedStatistics?: string[] + unit?: string } export interface CloudWatchGetMetricStatisticsResponse extends ToolResponse { @@ -117,6 +123,7 @@ export interface CloudWatchGetMetricStatisticsResponse extends ToolResponse { maximum?: number sampleCount?: number unit?: string + extendedStatistics?: Record }[] } } @@ -126,6 +133,7 @@ export interface CloudWatchDescribeAlarmsParams extends CloudWatchConnectionConf stateValue?: string alarmType?: string limit?: number + nextToken?: string } export interface CloudWatchDescribeAlarmsResponse extends ToolResponse { @@ -141,7 +149,27 @@ export interface CloudWatchDescribeAlarmsResponse extends ToolResponse { threshold: number | undefined evaluationPeriods: number | undefined stateUpdatedTimestamp: number | undefined + alarmType?: string + alarmDescription?: string + actionsEnabled?: boolean + alarmActions?: string[] + okActions?: string[] + insufficientDataActions?: string[] + stateReasonData?: string + stateTransitionedTimestamp?: number + alarmConfigurationUpdatedTimestamp?: number + statistic?: string + extendedStatistic?: string + dimensions?: { name: string; value: string }[] + period?: number + unit?: string + datapointsToAlarm?: number + treatMissingData?: string + thresholdMetricId?: string + metrics?: Record[] + alarmRule?: string }[] + nextToken?: string | null } } @@ -222,6 +250,8 @@ export interface CloudWatchDescribeAlarmHistoryResponse extends ToolResponse { timestamp: number | undefined historyItemType: string | undefined historySummary: string | undefined + historyData?: string + alarmContributorId?: string }[] } } @@ -234,6 +264,7 @@ export interface CloudWatchFilterLogEventsParams extends CloudWatchConnectionCon endTime?: number startFromHead?: boolean limit?: number + nextToken?: string } export interface CloudWatchFilterLogEventsResponse extends ToolResponse { @@ -243,7 +274,9 @@ export interface CloudWatchFilterLogEventsResponse extends ToolResponse { timestamp: number | undefined message: string | undefined ingestionTime: number | undefined + eventId?: string }[] + nextToken?: string | null } } diff --git a/apps/sim/tools/confluence/create_comment.test.ts b/apps/sim/tools/confluence/create_comment.test.ts new file mode 100644 index 00000000000..6369c8adb6e --- /dev/null +++ b/apps/sim/tools/confluence/create_comment.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + type ConfluenceCreateCommentParams, + confluenceCreateCommentTool, +} from '@/tools/confluence/create_comment' + +function operationInput(parentCommentId: unknown) { + return confluenceCreateCommentTool.operation.input({ + accessToken: 'token', + domain: 'example.atlassian.net', + pageId: '123', + comment: 'Reply', + parentCommentId, + } as ConfluenceCreateCommentParams) as Record +} + +describe('confluence_create_comment operation input', () => { + it.each([ + [456, '456'], + [' 456 ', '456'], + ['', undefined], + [undefined, undefined], + ])('normalizes parentCommentId %j to %j', (parentCommentId, expected) => { + expect(operationInput(parentCommentId).parentCommentId).toBe(expected) + }) +}) diff --git a/apps/sim/tools/confluence/create_comment.ts b/apps/sim/tools/confluence/create_comment.ts index b803fa35885..1232f6c045f 100644 --- a/apps/sim/tools/confluence/create_comment.ts +++ b/apps/sim/tools/confluence/create_comment.ts @@ -1,3 +1,4 @@ +import { VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types' import type { InternalToolConfig } from '@/tools/types' export interface ConfluenceCreateCommentParams { @@ -5,6 +6,7 @@ export interface ConfluenceCreateCommentParams { domain: string pageId: string comment: string + parentCommentId?: string cloudId?: string } @@ -14,6 +16,10 @@ export interface ConfluenceCreateCommentResponse { ts: string commentId: string pageId: string + parentCommentId: string | null + status: string | null + version: Record | null + webUrl: string | null } } @@ -56,6 +62,13 @@ export const confluenceCreateCommentTool: InternalToolConfig< visibility: 'user-or-llm', description: 'Comment text in Confluence storage format', }, + parentCommentId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ID of a footer or inline comment to reply to. When set, the comment is posted as a reply in that thread.', + }, cloudId: { type: 'string', required: false, @@ -73,6 +86,7 @@ export const confluenceCreateCommentTool: InternalToolConfig< cloudId: params.cloudId, pageId: params.pageId, comment: params.comment, + parentCommentId: String(params.parentCommentId ?? '').trim() || undefined, } }, }, @@ -85,6 +99,10 @@ export const confluenceCreateCommentTool: InternalToolConfig< ts: new Date().toISOString(), commentId: data.id, pageId: data.pageId || '', + parentCommentId: data.parentCommentId ?? null, + status: data.status ?? null, + version: data.version ?? null, + webUrl: data._links?.webui ?? null, }, } }, @@ -93,5 +111,22 @@ export const confluenceCreateCommentTool: InternalToolConfig< ts: { type: 'string', description: 'Timestamp of creation' }, commentId: { type: 'string', description: 'Created comment ID' }, pageId: { type: 'string', description: 'Page ID' }, + parentCommentId: { + type: 'string', + description: 'ID of the parent comment when the comment is a reply', + optional: true, + }, + status: { type: 'string', description: 'Comment status (e.g., current)', optional: true }, + version: { + type: 'object', + description: 'Comment version information', + properties: VERSION_OUTPUT_PROPERTIES, + optional: true, + }, + webUrl: { + type: 'string', + description: 'URL to view the comment in Confluence', + optional: true, + }, }, } diff --git a/apps/sim/tools/confluence/get_blogpost.ts b/apps/sim/tools/confluence/get_blogpost.ts index b9b89c8e0aa..e9bd90984a0 100644 --- a/apps/sim/tools/confluence/get_blogpost.ts +++ b/apps/sim/tools/confluence/get_blogpost.ts @@ -1,5 +1,6 @@ import { CONTENT_BODY_OUTPUT_PROPERTIES, + CONTENT_LABELS_OUTPUT, TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES, } from '@/tools/confluence/types' @@ -32,6 +33,7 @@ export interface ConfluenceGetBlogPostResponse { storage?: { value: string } } | null webUrl: string | null + labels: Array<{ id: string; name: string; prefix: string }> } } @@ -72,7 +74,8 @@ export const confluenceGetBlogPostTool: InternalToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'Format for blog post body: storage, atlas_doc_format, or view', + description: + 'Format for blog post body: storage, atlas_doc_format, view, or export_view (default: storage)', }, cloudId: { type: 'string', @@ -108,6 +111,7 @@ export const confluenceGetBlogPostTool: InternalToolConfig< version: data.version ?? null, body: data.body ?? null, webUrl: data.webUrl ?? null, + labels: data.labels ?? [], }, } }, @@ -133,5 +137,6 @@ export const confluenceGetBlogPostTool: InternalToolConfig< optional: true, }, webUrl: { type: 'string', description: 'URL to view the blog post', optional: true }, + labels: CONTENT_LABELS_OUTPUT, }, } diff --git a/apps/sim/tools/confluence/get_space.ts b/apps/sim/tools/confluence/get_space.ts index 99f21248f7c..651c29e52ee 100644 --- a/apps/sim/tools/confluence/get_space.ts +++ b/apps/sim/tools/confluence/get_space.ts @@ -25,6 +25,7 @@ export interface ConfluenceGetSpaceResponse { value: string representation: string } | null + spaceOwnerId: string | null } } @@ -99,7 +100,8 @@ export const confluenceGetSpaceTool: InternalToolConfig< authorId: data.authorId ?? null, createdAt: data.createdAt ?? null, homepageId: data.homepageId ?? null, - description: data.description ?? null, + description: data.description?.plain ?? null, + spaceOwnerId: data.spaceOwnerId ?? null, }, } }, @@ -125,5 +127,10 @@ export const confluenceGetSpaceTool: InternalToolConfig< properties: SPACE_DESCRIPTION_OUTPUT_PROPERTIES, optional: true, }, + spaceOwnerId: { + type: 'string', + description: 'Account ID of the space owner', + optional: true, + }, }, } diff --git a/apps/sim/tools/confluence/list_blogposts_in_space.ts b/apps/sim/tools/confluence/list_blogposts_in_space.ts index af7125d87e9..4b6d1fc73e0 100644 --- a/apps/sim/tools/confluence/list_blogposts_in_space.ts +++ b/apps/sim/tools/confluence/list_blogposts_in_space.ts @@ -90,7 +90,8 @@ export const confluenceListBlogPostsInSpaceTool: InternalToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'Format for blog post body: storage, atlas_doc_format, or view', + description: + 'Format for blog post body: storage or atlas_doc_format. If not specified, body is not included.', }, cursor: { type: 'string', diff --git a/apps/sim/tools/confluence/list_comments.ts b/apps/sim/tools/confluence/list_comments.ts index af6a7c4f274..ef818be4127 100644 --- a/apps/sim/tools/confluence/list_comments.ts +++ b/apps/sim/tools/confluence/list_comments.ts @@ -1,4 +1,4 @@ -import { COMMENTS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/confluence/types' +import { COMMENT_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types' import type { InternalToolConfig } from '@/tools/types' export interface ConfluenceListCommentsParams { @@ -8,6 +8,7 @@ export interface ConfluenceListCommentsParams { limit?: number bodyFormat?: string cursor?: string + commentType?: 'footer' | 'inline' cloudId?: string } @@ -69,8 +70,7 @@ export const confluenceListCommentsTool: InternalToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: - 'Format for the comment body: storage, atlas_doc_format, view, or export_view (default: storage)', + description: 'Format for the comment body: storage or atlas_doc_format (default: storage)', }, cursor: { type: 'string', @@ -78,6 +78,13 @@ export const confluenceListCommentsTool: InternalToolConfig< visibility: 'user-or-llm', description: 'Pagination cursor from previous response', }, + commentType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Which comments to list: footer (comments at the bottom of the page) or inline (comments attached to highlighted text). Default: footer', + }, cloudId: { type: 'string', required: false, @@ -101,6 +108,9 @@ export const confluenceListCommentsTool: InternalToolConfig< if (params.cursor) { query.set('cursor', params.cursor) } + if (params.commentType) { + query.set('commentType', params.commentType) + } if (params.cloudId) { query.set('cloudId', params.cloudId) } @@ -122,7 +132,41 @@ export const confluenceListCommentsTool: InternalToolConfig< outputs: { ts: TIMESTAMP_OUTPUT, - comments: COMMENTS_OUTPUT, + comments: { + type: 'array', + description: 'Array of Confluence comments', + items: { + type: 'object', + properties: { + ...COMMENT_ITEM_PROPERTIES, + commentType: { + type: 'string', + description: 'Comment type that was listed (footer or inline)', + optional: true, + }, + resolutionStatus: { + type: 'string', + description: 'Inline comment resolution status (open, reopened, resolved, dangling)', + optional: true, + }, + inlineOriginalSelection: { + type: 'string', + description: 'Page text the inline comment is attached to', + optional: true, + }, + inlineMarkerRef: { + type: 'string', + description: 'Marker reference of the highlighted text for an inline comment', + optional: true, + }, + webUrl: { + type: 'string', + description: 'URL to view the comment in Confluence', + optional: true, + }, + }, + }, + }, nextCursor: { type: 'string', description: 'Cursor for fetching the next page of results', diff --git a/apps/sim/tools/confluence/list_pages_in_space.ts b/apps/sim/tools/confluence/list_pages_in_space.ts index a69f4130276..d480cc1dd1e 100644 --- a/apps/sim/tools/confluence/list_pages_in_space.ts +++ b/apps/sim/tools/confluence/list_pages_in_space.ts @@ -93,7 +93,7 @@ export const confluenceListPagesInSpaceTool: InternalToolConfig< required: false, visibility: 'user-or-llm', description: - 'Format for page body content: storage, atlas_doc_format, or view. If not specified, body is not included.', + 'Format for page body content: storage or atlas_doc_format. If not specified, body is not included.', }, cursor: { type: 'string', diff --git a/apps/sim/tools/confluence/retrieve.ts b/apps/sim/tools/confluence/retrieve.ts index 356448f4101..b0f510a1cd0 100644 --- a/apps/sim/tools/confluence/retrieve.ts +++ b/apps/sim/tools/confluence/retrieve.ts @@ -1,6 +1,7 @@ import type { ConfluenceRetrieveParams, ConfluenceRetrieveResponse } from '@/tools/confluence/types' import { BODY_FORMAT_PROPERTIES, + CONTENT_LABELS_OUTPUT, TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES, } from '@/tools/confluence/types' @@ -78,6 +79,22 @@ export const confluenceRetrieveTool: InternalToolConfig< spaceId: { type: 'string', description: 'ID of the space containing the page', optional: true }, parentId: { type: 'string', description: 'ID of the parent page', optional: true }, authorId: { type: 'string', description: 'Account ID of the page author', optional: true }, + ownerId: { + type: 'string', + description: 'Account ID of the current page owner', + optional: true, + }, + lastOwnerId: { + type: 'string', + description: 'Account ID of the previous page owner', + optional: true, + }, + parentType: { + type: 'string', + description: 'Type of the parent content (e.g., page, whiteboard, database, embed, folder)', + optional: true, + }, + labels: CONTENT_LABELS_OUTPUT, createdAt: { type: 'string', description: 'ISO 8601 timestamp when the page was created', diff --git a/apps/sim/tools/confluence/search.ts b/apps/sim/tools/confluence/search.ts index 3349ae07aa6..2589cd5d849 100644 --- a/apps/sim/tools/confluence/search.ts +++ b/apps/sim/tools/confluence/search.ts @@ -6,6 +6,8 @@ export interface ConfluenceSearchParams { domain: string query: string limit?: number + cql?: string + cursor?: string cloudId?: string } @@ -20,6 +22,8 @@ export interface ConfluenceSearchResponse { url: string excerpt: string }> + totalSize: number | null + nextCursor: string | null } } @@ -62,6 +66,19 @@ export const confluenceSearchTool: InternalToolConfig< visibility: 'user-or-llm', description: 'Maximum number of results to return (default: 25)', }, + cql: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Extra CQL clauses ANDed with the text search, e.g. type = page AND space = "ENG" AND lastModified > now("-7d")', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous response (nextCursor)', + }, cloudId: { type: 'string', required: false, @@ -79,6 +96,8 @@ export const confluenceSearchTool: InternalToolConfig< cloudId: params.cloudId, query: params.query, limit: params.limit ? Number(params.limit) : 25, + cql: params.cql?.trim() || undefined, + cursor: params.cursor?.trim() || undefined, } }, }, @@ -90,6 +109,8 @@ export const confluenceSearchTool: InternalToolConfig< output: { ts: new Date().toISOString(), results: data.results || [], + totalSize: data.totalSize ?? null, + nextCursor: data.nextCursor ?? null, }, } }, @@ -104,5 +125,15 @@ export const confluenceSearchTool: InternalToolConfig< properties: SEARCH_RESULT_ITEM_PROPERTIES, }, }, + totalSize: { + type: 'number', + description: 'Total number of matching results', + optional: true, + }, + nextCursor: { + type: 'string', + description: 'Cursor for fetching the next page of results', + optional: true, + }, }, } diff --git a/apps/sim/tools/confluence/search_in_space.ts b/apps/sim/tools/confluence/search_in_space.ts index 06b3487b430..1a1646f1229 100644 --- a/apps/sim/tools/confluence/search_in_space.ts +++ b/apps/sim/tools/confluence/search_in_space.ts @@ -8,6 +8,7 @@ export interface ConfluenceSearchInSpaceParams { query?: string contentType?: string limit?: number + cursor?: string cloudId?: string } @@ -26,6 +27,7 @@ export interface ConfluenceSearchInSpaceResponse { excerpt: string lastModified: string | null }> + nextCursor: string | null } } @@ -81,6 +83,12 @@ export const confluenceSearchInSpaceTool: InternalToolConfig< visibility: 'user-or-llm', description: 'Maximum number of results to return (default: 25, max: 250)', }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous response (nextCursor)', + }, cloudId: { type: 'string', required: false, @@ -98,6 +106,7 @@ export const confluenceSearchInSpaceTool: InternalToolConfig< query: params.query, contentType: params.contentType, limit: params.limit ? Number(params.limit) : 25, + cursor: params.cursor?.trim() || undefined, cloudId: params.cloudId, }), }, @@ -111,6 +120,7 @@ export const confluenceSearchInSpaceTool: InternalToolConfig< spaceKey: data.spaceKey ?? '', totalSize: data.totalSize ?? 0, results: data.results ?? [], + nextCursor: data.nextCursor ?? null, }, } }, @@ -133,5 +143,10 @@ export const confluenceSearchInSpaceTool: InternalToolConfig< properties: SEARCH_RESULT_ITEM_PROPERTIES, }, }, + nextCursor: { + type: 'string', + description: 'Cursor for fetching the next page of results', + optional: true, + }, }, } diff --git a/apps/sim/tools/confluence/types.ts b/apps/sim/tools/confluence/types.ts index 2e9e092a1d0..7871dde1fd5 100644 --- a/apps/sim/tools/confluence/types.ts +++ b/apps/sim/tools/confluence/types.ts @@ -78,6 +78,11 @@ export const PAGE_ITEM_PROPERTIES = { optional: true, }, authorId: { type: 'string', description: 'Account ID of the page author' }, + ownerId: { + type: 'string', + description: 'Account ID of the current page owner', + optional: true, + }, createdAt: { type: 'string', description: 'ISO 8601 timestamp when the page was created' }, version: { type: 'object', @@ -126,10 +131,16 @@ export const SPACE_ITEM_PROPERTIES = { homepageId: { type: 'string', description: 'ID of the space homepage', optional: true }, description: { type: 'object', - description: 'Space description', + description: 'Space description (plain text)', properties: SPACE_DESCRIPTION_OUTPUT_PROPERTIES, optional: true, }, + spaceOwnerId: { + type: 'string', + description: 'Account ID of the space owner', + optional: true, + }, + webUrl: { type: 'string', description: 'URL to view the space in Confluence', optional: true }, } as const satisfies Record /** @@ -234,18 +245,6 @@ export const COMMENT_OUTPUT: OutputProperty = { properties: COMMENT_ITEM_PROPERTIES, } -/** - * Comments array output definition for list endpoints. - */ -export const COMMENTS_OUTPUT: OutputProperty = { - type: 'array', - description: 'Array of Confluence comments', - items: { - type: 'object', - properties: COMMENT_ITEM_PROPERTIES, - }, -} - /** * Attachment item properties from Confluence API v2. * Based on GET /wiki/api/v2/attachments response structure. @@ -273,6 +272,16 @@ export const ATTACHMENT_ITEM_PROPERTIES = { optional: true, }, comment: { type: 'string', description: 'Comment/description of the attachment', optional: true }, + createdAt: { + type: 'string', + description: 'ISO 8601 timestamp when the attachment was created', + optional: true, + }, + fileId: { + type: 'string', + description: 'Media file ID of the attachment', + optional: true, + }, version: { type: 'object', description: 'Attachment version information', @@ -312,6 +321,19 @@ export const LABEL_ITEM_PROPERTIES = { prefix: { type: 'string', description: 'Label prefix/type (e.g., global, my, team)' }, } as const satisfies Record +/** + * Labels returned inline on a page or blog post (first 50, via include-labels). + */ +export const CONTENT_LABELS_OUTPUT: OutputProperty = { + type: 'array', + description: 'Labels on the content (first 50)', + items: { + type: 'object', + properties: LABEL_ITEM_PROPERTIES, + }, + optional: true, +} + /** * Complete label object output definition. */ @@ -427,6 +449,10 @@ export interface ConfluenceRetrieveResponse extends ToolResponse { pageId: string content: string title: string + ownerId?: string | null + lastOwnerId?: string | null + parentType?: string | null + labels?: Array<{ id: string; name: string; prefix: string }> } } diff --git a/apps/sim/tools/confluence/utils.ts b/apps/sim/tools/confluence/utils.ts index b18263cbcc4..4753708eaab 100644 --- a/apps/sim/tools/confluence/utils.ts +++ b/apps/sim/tools/confluence/utils.ts @@ -91,6 +91,10 @@ export function transformPageData(data: any) { url: data._links?.webui ?? null, body: data.body ?? null, version: data.version ?? null, + ownerId: data.ownerId ?? null, + lastOwnerId: data.lastOwnerId ?? null, + parentType: data.parentType ?? null, + labels: data.labels ?? [], }, } } diff --git a/apps/sim/tools/dynamodb/delete.ts b/apps/sim/tools/dynamodb/delete.ts index d4eb9635f74..9d81fd91967 100644 --- a/apps/sim/tools/dynamodb/delete.ts +++ b/apps/sim/tools/dynamodb/delete.ts @@ -62,6 +62,12 @@ export const deleteTool: InternalToolConfig visibility: 'user-or-llm', description: 'Use strongly consistent read', }, + projectionExpression: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Attributes to retrieve (e.g., "pk, #name, email"). Returns all attributes if omitted', + }, + expressionAttributeNames: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Attribute name mappings for reserved words used in projectionExpression (e.g., {"#name": "name"})', + }, }, operation: { @@ -57,6 +71,11 @@ export const getTool: InternalToolConfig tableName: params.tableName, key: params.key, ...(params.consistentRead !== undefined && { consistentRead: params.consistentRead }), + ...(params.projectionExpression && { projectionExpression: params.projectionExpression }), + ...(params.projectionExpression && + params.expressionAttributeNames && { + expressionAttributeNames: params.expressionAttributeNames, + }), }), }, diff --git a/apps/sim/tools/dynamodb/introspect.ts b/apps/sim/tools/dynamodb/introspect.ts index 3f102777cfc..2a73c6b1f36 100644 --- a/apps/sim/tools/dynamodb/introspect.ts +++ b/apps/sim/tools/dynamodb/introspect.ts @@ -73,7 +73,8 @@ export const introspectTool: InternalToolConfig< tables: { type: 'array', description: 'List of table names in the region' }, tableDetails: { type: 'json', - description: 'Detailed schema information for a specific table', + description: + 'Detailed schema for a specific table (tableName, tableStatus, keySchema, attributeDefinitions, globalSecondaryIndexes, localSecondaryIndexes, itemCount, tableSizeBytes, billingMode, tableArn, creationDateTime, deletionProtectionEnabled, streamEnabled, streamViewType, latestStreamArn)', optional: true, }, }, diff --git a/apps/sim/tools/dynamodb/put.ts b/apps/sim/tools/dynamodb/put.ts index c3a099527fe..d916262dbfc 100644 --- a/apps/sim/tools/dynamodb/put.ts +++ b/apps/sim/tools/dynamodb/put.ts @@ -62,6 +62,13 @@ export const putTool: InternalToolConfig description: 'Expression attribute values used in conditionExpression (e.g., {":expected": "value"})', }, + returnValues: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Set to "ALL_OLD" to return the item this put replaced as attributes (default: "NONE")', + }, }, operation: { @@ -78,6 +85,7 @@ export const putTool: InternalToolConfig ...(params.expressionAttributeValues && { expressionAttributeValues: params.expressionAttributeValues, }), + ...(params.returnValues && { returnValues: params.returnValues }), }), }, @@ -93,6 +101,7 @@ export const putTool: InternalToolConfig output: { message: data.message || 'Item created successfully', item: data.item, + ...(data.attributes !== undefined && { attributes: data.attributes ?? null }), }, error: undefined, } @@ -101,5 +110,10 @@ export const putTool: InternalToolConfig outputs: { message: { type: 'string', description: 'Operation status message' }, item: { type: 'json', description: 'Created item', optional: true }, + attributes: { + type: 'json', + description: 'Item that was replaced, when returnValues is ALL_OLD (null if no item existed)', + optional: true, + }, }, } diff --git a/apps/sim/tools/dynamodb/query.ts b/apps/sim/tools/dynamodb/query.ts index d7c70462afd..2cd11c2f9b2 100644 --- a/apps/sim/tools/dynamodb/query.ts +++ b/apps/sim/tools/dynamodb/query.ts @@ -85,6 +85,19 @@ export const queryTool: InternalToolConfig consistentRead?: boolean + projectionExpression?: string + expressionAttributeNames?: Record } export interface DynamoDBPutParams extends DynamoDBConnectionConfig { @@ -18,6 +20,7 @@ export interface DynamoDBPutParams extends DynamoDBConnectionConfig { conditionExpression?: string expressionAttributeNames?: Record expressionAttributeValues?: Record + returnValues?: 'NONE' | 'ALL_OLD' } export interface DynamoDBQueryParams extends DynamoDBConnectionConfig { @@ -30,6 +33,8 @@ export interface DynamoDBQueryParams extends DynamoDBConnectionConfig { limit?: number exclusiveStartKey?: Record scanIndexForward?: boolean + projectionExpression?: string + consistentRead?: boolean } export interface DynamoDBScanParams extends DynamoDBConnectionConfig { @@ -40,6 +45,10 @@ export interface DynamoDBScanParams extends DynamoDBConnectionConfig { expressionAttributeValues?: Record limit?: number exclusiveStartKey?: Record + indexName?: string + consistentRead?: boolean + segment?: number + totalSegments?: number } export interface DynamoDBUpdateParams extends DynamoDBConnectionConfig { @@ -49,6 +58,7 @@ export interface DynamoDBUpdateParams extends DynamoDBConnectionConfig { expressionAttributeNames?: Record expressionAttributeValues?: Record conditionExpression?: string + returnValues?: 'NONE' | 'ALL_OLD' | 'UPDATED_OLD' | 'ALL_NEW' | 'UPDATED_NEW' } export interface DynamoDBDeleteParams extends DynamoDBConnectionConfig { @@ -57,6 +67,7 @@ export interface DynamoDBDeleteParams extends DynamoDBConnectionConfig { conditionExpression?: string expressionAttributeNames?: Record expressionAttributeValues?: Record + returnValues?: 'NONE' | 'ALL_OLD' } interface DynamoDBBaseResponse extends ToolResponse { @@ -65,7 +76,9 @@ interface DynamoDBBaseResponse extends ToolResponse { item?: Record items?: Record[] count?: number + scannedCount?: number | null lastEvaluatedKey?: Record + attributes?: Record | null } } @@ -106,6 +119,12 @@ export interface DynamoDBTableSchema { itemCount: number tableSizeBytes: number billingMode: string + tableArn?: string | null + creationDateTime?: string | null + deletionProtectionEnabled?: boolean | null + streamEnabled?: boolean | null + streamViewType?: string | null + latestStreamArn?: string | null } export interface DynamoDBIntrospectResponse extends ToolResponse { diff --git a/apps/sim/tools/dynamodb/update.ts b/apps/sim/tools/dynamodb/update.ts index 8393d9a28d0..1dec7989dce 100644 --- a/apps/sim/tools/dynamodb/update.ts +++ b/apps/sim/tools/dynamodb/update.ts @@ -68,6 +68,13 @@ export const updateTool: InternalToolConfig }[] nextToken: string | null diff --git a/apps/sim/tools/generated/tool-ids.ts b/apps/sim/tools/generated/tool-ids.ts index 54e950e3c5c..bd7f48bdeb7 100644 --- a/apps/sim/tools/generated/tool-ids.ts +++ b/apps/sim/tools/generated/tool-ids.ts @@ -3,7 +3,7 @@ /** Every registered tool id, including versioned variants. */ const toolIds: string[] = JSON.parse( - '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_named_query","athena_batch_get_prepared_statement","athena_batch_get_query_execution","athena_create_named_query","athena_create_prepared_statement","athena_delete_named_query","athena_delete_prepared_statement","athena_get_data_catalog","athena_get_database","athena_get_named_query","athena_get_prepared_statement","athena_get_query_execution","athena_get_query_results","athena_get_query_runtime_statistics","athena_get_table_metadata","athena_get_work_group","athena_list_data_catalogs","athena_list_databases","athena_list_named_queries","athena_list_prepared_statements","athena_list_query_executions","athena_list_table_metadata","athena_list_work_groups","athena_start_query","athena_stop_query","athena_update_named_query","athena_update_prepared_statement","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_download_file_v2","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_aggregated_post_metrics","buffer_get_channel","buffer_get_channels","buffer_get_daily_posting_limits","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","checkr_add_report_tag","checkr_apply_report_review_action","checkr_cancel_adverse_action","checkr_cancel_continuous_check","checkr_cancel_invitation","checkr_cancel_subscription","checkr_complete_report","checkr_create_adverse_action","checkr_create_candidate","checkr_create_continuous_check","checkr_create_geo","checkr_create_invitation","checkr_create_report","checkr_create_subscription","checkr_delete_candidate_pii","checkr_delete_geo","checkr_get_account","checkr_get_adverse_action","checkr_get_candidate","checkr_get_continuous_check","checkr_get_document","checkr_get_geo","checkr_get_invitation","checkr_get_node","checkr_get_package","checkr_get_program","checkr_get_report","checkr_get_report_eta","checkr_get_report_progressive_status","checkr_get_report_tags","checkr_get_screening","checkr_get_subscription","checkr_get_verification","checkr_list_adverse_actions","checkr_list_adverse_items","checkr_list_assessments","checkr_list_candidate_documents","checkr_list_candidates","checkr_list_continuous_checks","checkr_list_counties","checkr_list_geos","checkr_list_invitations","checkr_list_nodes","checkr_list_packages","checkr_list_programs","checkr_list_report_addresses","checkr_list_subscriptions","checkr_list_users","checkr_list_verifications","checkr_remove_report_tag","checkr_set_report_tags","checkr_update_candidate","checkr_update_continuous_check","checkr_update_geo","checkr_update_report","checkr_update_subscription","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_group","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_tunnel","cloudflare_create_worker_route","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_group","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_r2_bucket_cors","cloudflare_delete_ruleset","cloudflare_delete_ruleset_rule","cloudflare_delete_tunnel","cloudflare_delete_worker_route","cloudflare_delete_worker_script","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_access_group","cloudflare_get_access_policy","cloudflare_get_access_service_token","cloudflare_get_dns_record","cloudflare_get_r2_bucket","cloudflare_get_r2_bucket_cors","cloudflare_get_r2_bucket_lifecycle","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_tunnel_token","cloudflare_get_worker_route","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_refresh_access_service_token","cloudflare_revoke_access_service_token","cloudflare_rotate_access_service_token","cloudflare_update_access_application","cloudflare_update_access_group","cloudflare_update_access_policy","cloudflare_update_access_service_token","cloudflare_update_dns_record","cloudflare_update_r2_bucket_cors","cloudflare_update_r2_bucket_lifecycle","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset","cloudflare_update_ruleset_rule","cloudflare_update_tunnel","cloudflare_update_tunnel_configuration","cloudflare_update_worker_route","cloudflare_update_zone","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","coda_add_custom_domain","coda_add_permission","coda_change_user_role","coda_create_doc","coda_create_folder","coda_create_page","coda_delete_custom_domain","coda_delete_doc","coda_delete_folder","coda_delete_page","coda_delete_page_content","coda_delete_permission","coda_delete_row","coda_delete_rows","coda_export_page","coda_get_acl_settings","coda_get_analytics_last_updated","coda_get_column","coda_get_control","coda_get_custom_domain_provider","coda_get_doc","coda_get_doc_analytics_summary","coda_get_folder","coda_get_formula","coda_get_mutation_status","coda_get_page","coda_get_page_content","coda_get_page_export_status","coda_get_row","coda_get_sharing_metadata","coda_get_table","coda_list_categories","coda_list_columns","coda_list_controls","coda_list_custom_domains","coda_list_doc_analytics","coda_list_docs","coda_list_folder_children","coda_list_folders","coda_list_formulas","coda_list_page_analytics","coda_list_pages","coda_list_permissions","coda_list_rows","coda_list_tables","coda_list_workspace_members","coda_list_workspace_roles","coda_publish_doc","coda_push_button","coda_resolve_browser_link","coda_search_principals","coda_trigger_automation","coda_unpublish_doc","coda_update_acl_settings","coda_update_doc","coda_update_folder","coda_update_page","coda_update_row","coda_upsert_rows","coda_whoami","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_all_runs","databricks_cancel_run","databricks_cancel_statement","databricks_create_cluster","databricks_create_job","databricks_create_warehouse","databricks_delete_cluster","databricks_delete_job","databricks_delete_run","databricks_delete_warehouse","databricks_edit_cluster","databricks_edit_warehouse","databricks_execute_sql","databricks_genie_agent_ask","databricks_genie_agent_cancel","databricks_genie_agent_list_items","databricks_genie_ask","databricks_genie_create_space","databricks_genie_delete_conversation","databricks_genie_delete_message","databricks_genie_delete_space","databricks_genie_download_visualization","databricks_genie_execute_query","databricks_genie_get_message","databricks_genie_get_query_result","databricks_genie_get_space","databricks_genie_list_conversations","databricks_genie_list_messages","databricks_genie_list_spaces","databricks_genie_send_feedback","databricks_genie_update_space","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_get_statement_chunk","databricks_get_warehouse","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_repair_run","databricks_reset_job","databricks_resize_cluster","databricks_restart_cluster","databricks_run_job","databricks_start_cluster","databricks_start_warehouse","databricks_stop_warehouse","databricks_submit_run","databricks_terminate_cluster","databricks_update_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_download_v2","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_get_qr_code_v2","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","ec2_allocate_address","ec2_associate_address","ec2_authorize_security_group_ingress","ec2_create_snapshot","ec2_create_tags","ec2_delete_snapshot","ec2_delete_tags","ec2_delete_volume","ec2_describe_addresses","ec2_describe_availability_zones","ec2_describe_images","ec2_describe_instance_status","ec2_describe_instances","ec2_describe_internet_gateways","ec2_describe_key_pairs","ec2_describe_nat_gateways","ec2_describe_network_acls","ec2_describe_network_interfaces","ec2_describe_regions","ec2_describe_route_tables","ec2_describe_security_groups","ec2_describe_snapshots","ec2_describe_subnets","ec2_describe_volumes","ec2_describe_vpc_endpoints","ec2_describe_vpc_peering_connections","ec2_describe_vpcs","ec2_disassociate_address","ec2_reboot_instances","ec2_release_address","ec2_revoke_security_group_ingress","ec2_start_instances","ec2_stop_instances","ec2_terminate_instances","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_ollama","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_enrich","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","fivetran_cancel_transformation","fivetran_create_connect_card","fivetran_create_connection","fivetran_create_destination","fivetran_create_group","fivetran_delete_connection","fivetran_get_account_info","fivetran_get_connection","fivetran_get_connection_schema_config","fivetran_get_connection_warnings","fivetran_get_connector_type","fivetran_get_destination","fivetran_get_group","fivetran_get_sync_history","fivetran_get_transformation","fivetran_list_connections","fivetran_list_connector_types","fivetran_list_destinations","fivetran_list_groups","fivetran_list_transformations","fivetran_list_users","fivetran_reload_connection_schema","fivetran_resync_connection","fivetran_resync_tables","fivetran_run_connection_setup_tests","fivetran_run_destination_setup_tests","fivetran_run_transformation","fivetran_sync_connection","fivetran_update_connection","fivetran_update_connection_schema","fivetran_update_connection_table","fivetran_update_schema_config","flint_create_task","flint_generate_pages","flint_get_task","flow_add_link","flow_create_branch","flow_create_comment","flow_create_entities","flow_create_entity","flow_delete_comment","flow_delete_entity","flow_filter_entities","flow_get_comment","flow_get_data_model","flow_get_entities","flow_get_entity_history","flow_get_project","flow_list_branches","flow_list_comments","flow_list_entities","flow_list_projects","flow_remove_link","flow_restore_entity","flow_search_entities","flow_set_value","flow_set_values","flow_update_comment","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","gauge_add_tags_to_prompts","gauge_bulk_update_prompts","gauge_create_prompts","gauge_create_tag","gauge_create_topic","gauge_delete_tag","gauge_get_answer","gauge_get_category","gauge_get_current_credential","gauge_get_organization","gauge_get_prompt","gauge_get_query_metadata","gauge_get_tag","gauge_get_topic","gauge_list_answers","gauge_list_brands","gauge_list_categories","gauge_list_citations","gauge_list_organizations","gauge_list_prompts","gauge_list_tags","gauge_list_topics","gauge_query","gauge_remove_tags_from_prompts","gauge_rename_topic","gauge_update_prompt","gauge_update_tag","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_respond","google_calendar_respond_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_apply_candidate_tag_v2","greenhouse_convert_prospect_v2","greenhouse_create_application_v2","greenhouse_create_attachment_v2","greenhouse_create_candidate_tag_v2","greenhouse_create_candidate_v2","greenhouse_create_note_v2","greenhouse_delete_application_v2","greenhouse_delete_attachment_v2","greenhouse_delete_candidate_v2","greenhouse_get_application","greenhouse_get_application_v2","greenhouse_get_candidate","greenhouse_get_candidate_v2","greenhouse_get_job","greenhouse_get_job_v2","greenhouse_get_user","greenhouse_get_user_v2","greenhouse_hire_application_v2","greenhouse_list_application_stages_v2","greenhouse_list_applications","greenhouse_list_applications_v2","greenhouse_list_applied_candidate_tags_v2","greenhouse_list_attachments_v2","greenhouse_list_candidate_educations_v2","greenhouse_list_candidate_employments_v2","greenhouse_list_candidate_tags_v2","greenhouse_list_candidates","greenhouse_list_candidates_v2","greenhouse_list_close_reasons_v2","greenhouse_list_custom_field_options_v2","greenhouse_list_custom_fields_v2","greenhouse_list_departments","greenhouse_list_departments_v2","greenhouse_list_email_templates_v2","greenhouse_list_interviews_v2","greenhouse_list_job_hiring_managers_v2","greenhouse_list_job_interview_stages_v2","greenhouse_list_job_owners_v2","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_jobs_v2","greenhouse_list_notes_v2","greenhouse_list_offers_v2","greenhouse_list_offices","greenhouse_list_offices_v2","greenhouse_list_openings_v2","greenhouse_list_prospect_pool_stages_v2","greenhouse_list_prospect_pools_v2","greenhouse_list_referrers_v2","greenhouse_list_rejection_reasons_v2","greenhouse_list_scorecards_v2","greenhouse_list_sources_v2","greenhouse_list_users","greenhouse_list_users_v2","greenhouse_move_application_v2","greenhouse_reject_application_v2","greenhouse_remove_candidate_tag_v2","greenhouse_unreject_application_v2","greenhouse_update_application_v2","greenhouse_update_candidate_v2","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","helpscout_assign_conversation","helpscout_create_conversation","helpscout_create_customer","helpscout_create_customer_thread","helpscout_create_note","helpscout_create_reply","helpscout_delete_conversation","helpscout_get_conversation","helpscout_get_current_user","helpscout_get_customer","helpscout_get_organization","helpscout_get_saved_reply","helpscout_list_conversations","helpscout_list_custom_fields","helpscout_list_customer_properties","helpscout_list_customers","helpscout_list_folders","helpscout_list_mailboxes","helpscout_list_organizations","helpscout_list_saved_replies","helpscout_list_tags","helpscout_list_teams","helpscout_list_threads","helpscout_list_users","helpscout_list_workflows","helpscout_move_conversation","helpscout_run_workflow","helpscout_set_conversation_tags","helpscout_snooze_conversation","helpscout_unassign_conversation","helpscout_unsnooze_conversation","helpscout_update_conversation_customer","helpscout_update_conversation_status","helpscout_update_conversation_subject","helpscout_update_custom_fields","helpscout_update_customer","helpscout_update_customer_properties","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","higgsfield_cancel_request","higgsfield_create_character","higgsfield_delete_character","higgsfield_delete_request","higgsfield_generate","higgsfield_generate_soul_image","higgsfield_get_character","higgsfield_get_request_status","higgsfield_list_characters","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_policy","iam_get_role","iam_get_user","iam_list_access_keys","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","iam_update_access_key","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","intune_get_compliance_policy","intune_get_detected_app","intune_get_device","intune_get_device_configuration","intune_list_app_devices","intune_list_compliance_policies","intune_list_compliance_policy_device_statuses","intune_list_detected_apps","intune_list_device_configurations","intune_list_devices","intune_reboot_device","intune_remote_lock_device","intune_retire_device","intune_sync_device","jama_create_comment","jama_create_item","jama_create_relationship","jama_delete_item","jama_delete_relationship","jama_get_baseline","jama_get_current_user","jama_get_filter","jama_get_item","jama_get_item_type","jama_get_item_version","jama_get_project","jama_get_relationship","jama_get_test_cycle","jama_get_test_plan","jama_get_test_run","jama_list_baseline_items","jama_list_baselines","jama_list_downstream_items","jama_list_filter_results","jama_list_filters","jama_list_item_children","jama_list_item_comments","jama_list_item_types","jama_list_item_versions","jama_list_items","jama_list_pick_list_options","jama_list_pick_lists","jama_list_projects","jama_list_relationship_types","jama_list_relationships","jama_list_releases","jama_list_tags","jama_list_test_cycles","jama_list_test_plans","jama_list_test_runs","jama_list_upstream_items","jama_search_items","jama_update_item","jama_update_test_run","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_create_sprint","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_get_content_v2","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mcp_list_operations","mcp_run_operation","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_download_file_v2","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","microsoft_excel_calculate","microsoft_excel_clear_range","microsoft_excel_close_session","microsoft_excel_create_session","microsoft_excel_create_table","microsoft_excel_delete_table","microsoft_excel_delete_table_row","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_get_table","microsoft_excel_get_table_row","microsoft_excel_get_worksheet","microsoft_excel_list_table_rows","microsoft_excel_list_tables","microsoft_excel_list_worksheets","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_refresh_session","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_update_table","microsoft_excel_update_table_row","microsoft_excel_update_worksheet","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","modal_call_function","modal_chat_completion","modal_list_models","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","oracle_fusion_financials_apply_receivables_receipt","oracle_fusion_financials_approve_receivables_invoice","oracle_fusion_financials_create_expense_line","oracle_fusion_financials_create_expense_report","oracle_fusion_financials_create_receivables_credit_memo","oracle_fusion_financials_create_receivables_invoice","oracle_fusion_financials_create_receivables_receipt","oracle_fusion_financials_get_expense_line","oracle_fusion_financials_get_expense_report","oracle_fusion_financials_get_gl_journal_batch","oracle_fusion_financials_get_gl_ledger","oracle_fusion_financials_get_payables_invoice","oracle_fusion_financials_get_payables_invoice_line","oracle_fusion_financials_get_payables_payment","oracle_fusion_financials_get_receivables_credit_memo","oracle_fusion_financials_get_receivables_invoice","oracle_fusion_financials_get_receivables_invoice_installment","oracle_fusion_financials_get_receivables_invoice_line","oracle_fusion_financials_get_receivables_receipt","oracle_fusion_financials_list_expense_lines","oracle_fusion_financials_list_expense_reports","oracle_fusion_financials_list_gl_balances","oracle_fusion_financials_list_gl_journal_batches","oracle_fusion_financials_list_gl_ledgers","oracle_fusion_financials_list_payables_invoice_lines","oracle_fusion_financials_list_payables_invoices","oracle_fusion_financials_list_payables_payments","oracle_fusion_financials_list_receivables_credit_memos","oracle_fusion_financials_list_receivables_invoice_installments","oracle_fusion_financials_list_receivables_invoice_lines","oracle_fusion_financials_list_receivables_invoices","oracle_fusion_financials_list_receivables_receipts","oracle_fusion_financials_submit_expense_report","oracle_fusion_financials_update_expense_line","oracle_fusion_financials_update_expense_report","oracle_fusion_financials_update_receivables_invoice","oracledb_delete","oracledb_execute","oracledb_insert","oracledb_introspect","oracledb_query","oracledb_update","otter_create_conversation","otter_get_conversation","otter_get_conversation_audio","otter_get_workspace","otter_list_channel_members","otter_list_channels","otter_list_conversations","otter_list_workspace_conversations","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","plane_archive_project","plane_archive_work_item","plane_confirm_attachment_upload","plane_create_attachment_upload","plane_create_comment","plane_create_cycle","plane_create_label","plane_create_link","plane_create_module","plane_create_project","plane_create_project_page","plane_create_state","plane_create_webhook","plane_create_work_item","plane_delete_attachment","plane_delete_comment","plane_delete_cycle","plane_delete_label","plane_delete_link","plane_delete_module","plane_delete_project","plane_delete_project_page","plane_delete_state","plane_delete_webhook","plane_delete_work_item","plane_get_attachment","plane_get_comment","plane_get_current_user","plane_get_cycle","plane_get_label","plane_get_link","plane_get_module","plane_get_project","plane_get_project_page","plane_get_project_summary","plane_get_state","plane_get_webhook","plane_get_work_item","plane_get_work_item_by_identifier","plane_list_attachments","plane_list_comments","plane_list_cycles","plane_list_labels","plane_list_links","plane_list_modules","plane_list_project_pages","plane_list_projects","plane_list_states","plane_list_webhooks","plane_list_work_items","plane_list_workspace_work_items","plane_manage_cycle_work_items","plane_manage_module_work_items","plane_regenerate_webhook_secret","plane_transfer_cycle_work_items","plane_unarchive_project","plane_unarchive_work_item","plane_update_comment","plane_update_cycle","plane_update_label","plane_update_link","plane_update_module","plane_update_project","plane_update_project_page","plane_update_state","plane_update_webhook","plane_update_work_item","planetscale_close_deploy_request","planetscale_create_backup","planetscale_create_branch","planetscale_create_deploy_request","planetscale_delete_branch","planetscale_get_backup","planetscale_get_branch","planetscale_get_database","planetscale_get_deploy_request","planetscale_list_backups","planetscale_list_branches","planetscale_list_databases","planetscale_list_deploy_requests","planetscale_queue_deploy_request","planetscale_review_deploy_request","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","powerbi_execute_query","powerbi_get_dataset","powerbi_get_refresh_history","powerbi_get_report","powerbi_list_datasets","powerbi_list_reports","powerbi_list_workspaces","powerbi_refresh_dataset","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_bill_payment","quickbooks_void_customer_payment","quickbooks_void_invoice","quickbooks_void_sales_receipt","quiver_image_to_svg","quiver_image_to_svg_v2","quiver_list_models","quiver_text_to_svg","quiver_text_to_svg_v2","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","ramp_create_memo","ramp_get_balance","ramp_get_bill","ramp_get_business","ramp_get_memo","ramp_get_physical_card","ramp_get_reimbursement","ramp_get_transaction","ramp_get_user","ramp_get_vendor","ramp_get_virtual_card","ramp_list_bills","ramp_list_physical_cards","ramp_list_reimbursements","ramp_list_transactions","ramp_list_users","ramp_list_vendors","ramp_list_virtual_cards","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_download_attachment_v2","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_download_v2","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_create_product_variants","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_fulfillment","shopify_get_fulfillment_order","shopify_get_inventory_level","shopify_get_job","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_fulfillment_orders","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_fulfillment_tracking","shopify_update_order","shopify_update_product","shopify_update_product_variants","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_bookmark","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_close_conversation","slack_create_channel_canvas","slack_create_conversation","slack_create_user_group","slack_delete_canvas","slack_delete_file","slack_delete_message","slack_delete_scheduled_message","slack_disable_user_group","slack_download","slack_edit_bookmark","slack_edit_canvas","slack_enable_user_group","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_dnd_info","slack_get_file_info","slack_get_message","slack_get_permalink","slack_get_reactions","slack_get_team_dnd_info","slack_get_team_info","slack_get_team_profile","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_get_user_profile","slack_invite_to_conversation","slack_join_conversation","slack_kick_conversation","slack_leave_conversation","slack_list_bookmarks","slack_list_canvases","slack_list_channels","slack_list_emoji","slack_list_files","slack_list_members","slack_list_pins","slack_list_reactions","slack_list_scheduled_messages","slack_list_user_conversations","slack_list_user_group_members","slack_list_user_groups","slack_list_users","slack_lists_access_delete","slack_lists_access_set","slack_lists_create","slack_lists_download_get","slack_lists_download_start","slack_lists_items_create","slack_lists_items_delete","slack_lists_items_delete_multiple","slack_lists_items_info","slack_lists_items_list","slack_lists_items_update","slack_lists_update","slack_lookup_canvas_sections","slack_lookup_user_by_email","slack_mark_conversation_read","slack_message","slack_message_reader","slack_open_conversation","slack_open_view","slack_pin_message","slack_publish_view","slack_push_view","slack_remove_bookmark","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_revoke_canvas_access","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_set_user_presence","slack_share_canvas","slack_unarchive_conversation","slack_unfurl_links","slack_unpin_message","slack_update_message","slack_update_user_group","slack_update_user_group_members","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_cortex_analyst_ask","snowflake_cortex_analyst_feedback","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_download_file_v2","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_approve_user","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_device_attribute","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_attributes","tailscale_get_device_routes","tailscale_get_dns_configuration","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_get_split_dns","tailscale_get_tailnet_settings","tailscale_get_user","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_restore_user","tailscale_set_acl","tailscale_set_device_attribute","tailscale_set_device_name","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_configuration","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_set_split_dns","tailscale_set_user_role","tailscale_suspend_user","tailscale_update_device_key","tailscale_update_split_dns","tailscale_update_tailnet_settings","tailscale_validate_acl","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_higgsfield","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youcom_answer","youcom_finance_research","youcom_get_account_balance","youcom_get_contents","youcom_get_research_task","youcom_research","youcom_search","youcom_search_images","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_create_account","zoho_desk_create_contact","zoho_desk_create_ticket","zoho_desk_delete_comment","zoho_desk_get_account","zoho_desk_get_agent","zoho_desk_get_attachment","zoho_desk_get_comment","zoho_desk_get_contact","zoho_desk_get_department","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_accounts","zoho_desk_list_agents","zoho_desk_list_comments","zoho_desk_list_contacts","zoho_desk_list_departments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_move_ticket","zoho_desk_search_accounts","zoho_desk_search_contacts","zoho_desk_search_tickets","zoho_desk_send_reply","zoho_desk_update_account","zoho_desk_update_comment","zoho_desk_update_contact","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' + '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_named_query","athena_batch_get_prepared_statement","athena_batch_get_query_execution","athena_create_named_query","athena_create_prepared_statement","athena_delete_named_query","athena_delete_prepared_statement","athena_get_data_catalog","athena_get_database","athena_get_named_query","athena_get_prepared_statement","athena_get_query_execution","athena_get_query_results","athena_get_query_runtime_statistics","athena_get_table_metadata","athena_get_work_group","athena_list_data_catalogs","athena_list_databases","athena_list_named_queries","athena_list_prepared_statements","athena_list_query_executions","athena_list_table_metadata","athena_list_work_groups","athena_start_query","athena_stop_query","athena_update_named_query","athena_update_prepared_statement","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_download_file_v2","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_aggregated_post_metrics","buffer_get_channel","buffer_get_channels","buffer_get_daily_posting_limits","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","checkr_add_report_tag","checkr_apply_report_review_action","checkr_cancel_adverse_action","checkr_cancel_continuous_check","checkr_cancel_invitation","checkr_cancel_subscription","checkr_complete_report","checkr_create_adverse_action","checkr_create_candidate","checkr_create_continuous_check","checkr_create_geo","checkr_create_invitation","checkr_create_report","checkr_create_subscription","checkr_delete_candidate_pii","checkr_delete_geo","checkr_get_account","checkr_get_adverse_action","checkr_get_candidate","checkr_get_continuous_check","checkr_get_document","checkr_get_geo","checkr_get_invitation","checkr_get_node","checkr_get_package","checkr_get_program","checkr_get_report","checkr_get_report_eta","checkr_get_report_progressive_status","checkr_get_report_tags","checkr_get_screening","checkr_get_subscription","checkr_get_verification","checkr_list_adverse_actions","checkr_list_adverse_items","checkr_list_assessments","checkr_list_candidate_documents","checkr_list_candidates","checkr_list_continuous_checks","checkr_list_counties","checkr_list_geos","checkr_list_invitations","checkr_list_nodes","checkr_list_packages","checkr_list_programs","checkr_list_report_addresses","checkr_list_subscriptions","checkr_list_users","checkr_list_verifications","checkr_remove_report_tag","checkr_set_report_tags","checkr_update_candidate","checkr_update_continuous_check","checkr_update_geo","checkr_update_report","checkr_update_subscription","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_group","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_tunnel","cloudflare_create_worker_route","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_group","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_r2_bucket_cors","cloudflare_delete_ruleset","cloudflare_delete_ruleset_rule","cloudflare_delete_tunnel","cloudflare_delete_worker_route","cloudflare_delete_worker_script","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_access_group","cloudflare_get_access_policy","cloudflare_get_access_service_token","cloudflare_get_dns_record","cloudflare_get_r2_bucket","cloudflare_get_r2_bucket_cors","cloudflare_get_r2_bucket_lifecycle","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_tunnel_token","cloudflare_get_worker_route","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_refresh_access_service_token","cloudflare_revoke_access_service_token","cloudflare_rotate_access_service_token","cloudflare_update_access_application","cloudflare_update_access_group","cloudflare_update_access_policy","cloudflare_update_access_service_token","cloudflare_update_dns_record","cloudflare_update_r2_bucket_cors","cloudflare_update_r2_bucket_lifecycle","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset","cloudflare_update_ruleset_rule","cloudflare_update_tunnel","cloudflare_update_tunnel_configuration","cloudflare_update_worker_route","cloudflare_update_zone","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","coda_add_custom_domain","coda_add_permission","coda_change_user_role","coda_create_doc","coda_create_folder","coda_create_page","coda_delete_custom_domain","coda_delete_doc","coda_delete_folder","coda_delete_page","coda_delete_page_content","coda_delete_permission","coda_delete_row","coda_delete_rows","coda_export_page","coda_get_acl_settings","coda_get_analytics_last_updated","coda_get_column","coda_get_control","coda_get_custom_domain_provider","coda_get_doc","coda_get_doc_analytics_summary","coda_get_folder","coda_get_formula","coda_get_mutation_status","coda_get_page","coda_get_page_content","coda_get_page_export_status","coda_get_row","coda_get_sharing_metadata","coda_get_table","coda_list_categories","coda_list_columns","coda_list_controls","coda_list_custom_domains","coda_list_doc_analytics","coda_list_docs","coda_list_folder_children","coda_list_folders","coda_list_formulas","coda_list_page_analytics","coda_list_pages","coda_list_permissions","coda_list_rows","coda_list_tables","coda_list_workspace_members","coda_list_workspace_roles","coda_publish_doc","coda_push_button","coda_resolve_browser_link","coda_search_principals","coda_trigger_automation","coda_unpublish_doc","coda_update_acl_settings","coda_update_doc","coda_update_folder","coda_update_page","coda_update_row","coda_upsert_rows","coda_whoami","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_all_runs","databricks_cancel_run","databricks_cancel_statement","databricks_create_cluster","databricks_create_job","databricks_create_warehouse","databricks_delete_cluster","databricks_delete_job","databricks_delete_run","databricks_delete_warehouse","databricks_edit_cluster","databricks_edit_warehouse","databricks_execute_sql","databricks_genie_agent_ask","databricks_genie_agent_cancel","databricks_genie_agent_list_items","databricks_genie_ask","databricks_genie_create_space","databricks_genie_delete_conversation","databricks_genie_delete_message","databricks_genie_delete_space","databricks_genie_download_visualization","databricks_genie_execute_query","databricks_genie_get_message","databricks_genie_get_query_result","databricks_genie_get_space","databricks_genie_list_conversations","databricks_genie_list_messages","databricks_genie_list_spaces","databricks_genie_send_feedback","databricks_genie_update_space","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_get_statement_chunk","databricks_get_warehouse","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_repair_run","databricks_reset_job","databricks_resize_cluster","databricks_restart_cluster","databricks_run_job","databricks_start_cluster","databricks_start_warehouse","databricks_stop_warehouse","databricks_submit_run","databricks_terminate_cluster","databricks_update_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_download_v2","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_get_qr_code_v2","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","ec2_allocate_address","ec2_associate_address","ec2_authorize_security_group_ingress","ec2_create_snapshot","ec2_create_tags","ec2_delete_snapshot","ec2_delete_tags","ec2_delete_volume","ec2_describe_addresses","ec2_describe_availability_zones","ec2_describe_images","ec2_describe_instance_status","ec2_describe_instances","ec2_describe_internet_gateways","ec2_describe_key_pairs","ec2_describe_nat_gateways","ec2_describe_network_acls","ec2_describe_network_interfaces","ec2_describe_regions","ec2_describe_route_tables","ec2_describe_security_groups","ec2_describe_snapshots","ec2_describe_subnets","ec2_describe_volumes","ec2_describe_vpc_endpoints","ec2_describe_vpc_peering_connections","ec2_describe_vpcs","ec2_disassociate_address","ec2_reboot_instances","ec2_release_address","ec2_revoke_security_group_ingress","ec2_start_instances","ec2_stop_instances","ec2_terminate_instances","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_ollama","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_enrich","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","fivetran_cancel_transformation","fivetran_create_connect_card","fivetran_create_connection","fivetran_create_destination","fivetran_create_group","fivetran_delete_connection","fivetran_get_account_info","fivetran_get_connection","fivetran_get_connection_schema_config","fivetran_get_connection_warnings","fivetran_get_connector_type","fivetran_get_destination","fivetran_get_group","fivetran_get_sync_history","fivetran_get_transformation","fivetran_list_connections","fivetran_list_connector_types","fivetran_list_destinations","fivetran_list_groups","fivetran_list_transformations","fivetran_list_users","fivetran_reload_connection_schema","fivetran_resync_connection","fivetran_resync_tables","fivetran_run_connection_setup_tests","fivetran_run_destination_setup_tests","fivetran_run_transformation","fivetran_sync_connection","fivetran_update_connection","fivetran_update_connection_schema","fivetran_update_connection_table","fivetran_update_schema_config","flint_create_task","flint_generate_pages","flint_get_task","flow_add_link","flow_create_branch","flow_create_comment","flow_create_entities","flow_create_entity","flow_delete_comment","flow_delete_entity","flow_filter_entities","flow_get_comment","flow_get_data_model","flow_get_entities","flow_get_entity_history","flow_get_project","flow_list_branches","flow_list_comments","flow_list_entities","flow_list_projects","flow_remove_link","flow_restore_entity","flow_search_entities","flow_set_value","flow_set_values","flow_update_comment","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","gauge_add_tags_to_prompts","gauge_bulk_update_prompts","gauge_create_prompts","gauge_create_tag","gauge_create_topic","gauge_delete_tag","gauge_get_answer","gauge_get_category","gauge_get_current_credential","gauge_get_organization","gauge_get_prompt","gauge_get_query_metadata","gauge_get_tag","gauge_get_topic","gauge_list_answers","gauge_list_brands","gauge_list_categories","gauge_list_citations","gauge_list_organizations","gauge_list_prompts","gauge_list_tags","gauge_list_topics","gauge_query","gauge_remove_tags_from_prompts","gauge_rename_topic","gauge_update_prompt","gauge_update_tag","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_respond","google_calendar_respond_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_apply_candidate_tag_v2","greenhouse_convert_prospect_v2","greenhouse_create_application_v2","greenhouse_create_attachment_v2","greenhouse_create_candidate_tag_v2","greenhouse_create_candidate_v2","greenhouse_create_note_v2","greenhouse_delete_application_v2","greenhouse_delete_attachment_v2","greenhouse_delete_candidate_v2","greenhouse_get_application","greenhouse_get_application_v2","greenhouse_get_candidate","greenhouse_get_candidate_v2","greenhouse_get_job","greenhouse_get_job_v2","greenhouse_get_user","greenhouse_get_user_v2","greenhouse_hire_application_v2","greenhouse_list_application_stages_v2","greenhouse_list_applications","greenhouse_list_applications_v2","greenhouse_list_applied_candidate_tags_v2","greenhouse_list_attachments_v2","greenhouse_list_candidate_educations_v2","greenhouse_list_candidate_employments_v2","greenhouse_list_candidate_tags_v2","greenhouse_list_candidates","greenhouse_list_candidates_v2","greenhouse_list_close_reasons_v2","greenhouse_list_custom_field_options_v2","greenhouse_list_custom_fields_v2","greenhouse_list_departments","greenhouse_list_departments_v2","greenhouse_list_email_templates_v2","greenhouse_list_interviews_v2","greenhouse_list_job_hiring_managers_v2","greenhouse_list_job_interview_stages_v2","greenhouse_list_job_owners_v2","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_jobs_v2","greenhouse_list_notes_v2","greenhouse_list_offers_v2","greenhouse_list_offices","greenhouse_list_offices_v2","greenhouse_list_openings_v2","greenhouse_list_prospect_pool_stages_v2","greenhouse_list_prospect_pools_v2","greenhouse_list_referrers_v2","greenhouse_list_rejection_reasons_v2","greenhouse_list_scorecards_v2","greenhouse_list_sources_v2","greenhouse_list_users","greenhouse_list_users_v2","greenhouse_move_application_v2","greenhouse_reject_application_v2","greenhouse_remove_candidate_tag_v2","greenhouse_unreject_application_v2","greenhouse_update_application_v2","greenhouse_update_candidate_v2","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","helpscout_assign_conversation","helpscout_create_conversation","helpscout_create_customer","helpscout_create_customer_thread","helpscout_create_note","helpscout_create_reply","helpscout_delete_conversation","helpscout_get_conversation","helpscout_get_current_user","helpscout_get_customer","helpscout_get_organization","helpscout_get_saved_reply","helpscout_list_conversations","helpscout_list_custom_fields","helpscout_list_customer_properties","helpscout_list_customers","helpscout_list_folders","helpscout_list_mailboxes","helpscout_list_organizations","helpscout_list_saved_replies","helpscout_list_tags","helpscout_list_teams","helpscout_list_threads","helpscout_list_users","helpscout_list_workflows","helpscout_move_conversation","helpscout_run_workflow","helpscout_set_conversation_tags","helpscout_snooze_conversation","helpscout_unassign_conversation","helpscout_unsnooze_conversation","helpscout_update_conversation_customer","helpscout_update_conversation_status","helpscout_update_conversation_subject","helpscout_update_custom_fields","helpscout_update_customer","helpscout_update_customer_properties","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","higgsfield_cancel_request","higgsfield_create_character","higgsfield_delete_character","higgsfield_delete_request","higgsfield_generate","higgsfield_generate_soul_image","higgsfield_get_character","higgsfield_get_request_status","higgsfield_list_characters","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_policy","iam_get_role","iam_get_user","iam_list_access_keys","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","iam_update_access_key","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","intune_get_compliance_policy","intune_get_detected_app","intune_get_device","intune_get_device_configuration","intune_list_app_devices","intune_list_compliance_policies","intune_list_compliance_policy_device_statuses","intune_list_detected_apps","intune_list_device_configurations","intune_list_devices","intune_reboot_device","intune_remote_lock_device","intune_retire_device","intune_sync_device","jama_create_comment","jama_create_item","jama_create_relationship","jama_delete_item","jama_delete_relationship","jama_get_baseline","jama_get_current_user","jama_get_filter","jama_get_item","jama_get_item_type","jama_get_item_version","jama_get_project","jama_get_relationship","jama_get_test_cycle","jama_get_test_plan","jama_get_test_run","jama_list_baseline_items","jama_list_baselines","jama_list_downstream_items","jama_list_filter_results","jama_list_filters","jama_list_item_children","jama_list_item_comments","jama_list_item_types","jama_list_item_versions","jama_list_items","jama_list_pick_list_options","jama_list_pick_lists","jama_list_projects","jama_list_relationship_types","jama_list_relationships","jama_list_releases","jama_list_tags","jama_list_test_cycles","jama_list_test_plans","jama_list_test_runs","jama_list_upstream_items","jama_search_items","jama_update_item","jama_update_test_run","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_create_sprint","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_get_content_v2","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mcp_list_operations","mcp_run_operation","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_user_memberships","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_download_file_v2","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","microsoft_excel_calculate","microsoft_excel_clear_range","microsoft_excel_close_session","microsoft_excel_create_session","microsoft_excel_create_table","microsoft_excel_delete_table","microsoft_excel_delete_table_row","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_get_table","microsoft_excel_get_table_row","microsoft_excel_get_worksheet","microsoft_excel_list_table_rows","microsoft_excel_list_tables","microsoft_excel_list_worksheets","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_refresh_session","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_update_table","microsoft_excel_update_table_row","microsoft_excel_update_worksheet","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","modal_call_function","modal_chat_completion","modal_list_models","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","oracle_fusion_financials_apply_receivables_receipt","oracle_fusion_financials_approve_receivables_invoice","oracle_fusion_financials_create_expense_line","oracle_fusion_financials_create_expense_report","oracle_fusion_financials_create_receivables_credit_memo","oracle_fusion_financials_create_receivables_invoice","oracle_fusion_financials_create_receivables_receipt","oracle_fusion_financials_get_expense_line","oracle_fusion_financials_get_expense_report","oracle_fusion_financials_get_gl_journal_batch","oracle_fusion_financials_get_gl_ledger","oracle_fusion_financials_get_payables_invoice","oracle_fusion_financials_get_payables_invoice_line","oracle_fusion_financials_get_payables_payment","oracle_fusion_financials_get_receivables_credit_memo","oracle_fusion_financials_get_receivables_invoice","oracle_fusion_financials_get_receivables_invoice_installment","oracle_fusion_financials_get_receivables_invoice_line","oracle_fusion_financials_get_receivables_receipt","oracle_fusion_financials_list_expense_lines","oracle_fusion_financials_list_expense_reports","oracle_fusion_financials_list_gl_balances","oracle_fusion_financials_list_gl_journal_batches","oracle_fusion_financials_list_gl_ledgers","oracle_fusion_financials_list_payables_invoice_lines","oracle_fusion_financials_list_payables_invoices","oracle_fusion_financials_list_payables_payments","oracle_fusion_financials_list_receivables_credit_memos","oracle_fusion_financials_list_receivables_invoice_installments","oracle_fusion_financials_list_receivables_invoice_lines","oracle_fusion_financials_list_receivables_invoices","oracle_fusion_financials_list_receivables_receipts","oracle_fusion_financials_submit_expense_report","oracle_fusion_financials_update_expense_line","oracle_fusion_financials_update_expense_report","oracle_fusion_financials_update_receivables_invoice","oracledb_delete","oracledb_execute","oracledb_insert","oracledb_introspect","oracledb_query","oracledb_update","otter_create_conversation","otter_get_conversation","otter_get_conversation_audio","otter_get_workspace","otter_list_channel_members","otter_list_channels","otter_list_conversations","otter_list_workspace_conversations","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","plane_archive_project","plane_archive_work_item","plane_confirm_attachment_upload","plane_create_attachment_upload","plane_create_comment","plane_create_cycle","plane_create_label","plane_create_link","plane_create_module","plane_create_project","plane_create_project_page","plane_create_state","plane_create_webhook","plane_create_work_item","plane_delete_attachment","plane_delete_comment","plane_delete_cycle","plane_delete_label","plane_delete_link","plane_delete_module","plane_delete_project","plane_delete_project_page","plane_delete_state","plane_delete_webhook","plane_delete_work_item","plane_get_attachment","plane_get_comment","plane_get_current_user","plane_get_cycle","plane_get_label","plane_get_link","plane_get_module","plane_get_project","plane_get_project_page","plane_get_project_summary","plane_get_state","plane_get_webhook","plane_get_work_item","plane_get_work_item_by_identifier","plane_list_attachments","plane_list_comments","plane_list_cycles","plane_list_labels","plane_list_links","plane_list_modules","plane_list_project_pages","plane_list_projects","plane_list_states","plane_list_webhooks","plane_list_work_items","plane_list_workspace_work_items","plane_manage_cycle_work_items","plane_manage_module_work_items","plane_regenerate_webhook_secret","plane_transfer_cycle_work_items","plane_unarchive_project","plane_unarchive_work_item","plane_update_comment","plane_update_cycle","plane_update_label","plane_update_link","plane_update_module","plane_update_project","plane_update_project_page","plane_update_state","plane_update_webhook","plane_update_work_item","planetscale_close_deploy_request","planetscale_create_backup","planetscale_create_branch","planetscale_create_deploy_request","planetscale_delete_branch","planetscale_get_backup","planetscale_get_branch","planetscale_get_database","planetscale_get_deploy_request","planetscale_list_backups","planetscale_list_branches","planetscale_list_databases","planetscale_list_deploy_requests","planetscale_queue_deploy_request","planetscale_review_deploy_request","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","powerbi_execute_query","powerbi_get_dataset","powerbi_get_refresh_history","powerbi_get_report","powerbi_list_datasets","powerbi_list_reports","powerbi_list_workspaces","powerbi_refresh_dataset","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_bill_payment","quickbooks_void_customer_payment","quickbooks_void_invoice","quickbooks_void_sales_receipt","quiver_image_to_svg","quiver_image_to_svg_v2","quiver_list_models","quiver_text_to_svg","quiver_text_to_svg_v2","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","ramp_create_memo","ramp_get_balance","ramp_get_bill","ramp_get_business","ramp_get_memo","ramp_get_physical_card","ramp_get_reimbursement","ramp_get_transaction","ramp_get_user","ramp_get_vendor","ramp_get_virtual_card","ramp_list_bills","ramp_list_physical_cards","ramp_list_reimbursements","ramp_list_transactions","ramp_list_users","ramp_list_vendors","ramp_list_virtual_cards","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_download_attachment_v2","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_download_v2","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_create_product_variants","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_fulfillment","shopify_get_fulfillment_order","shopify_get_inventory_level","shopify_get_job","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_fulfillment_orders","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_fulfillment_tracking","shopify_update_order","shopify_update_product","shopify_update_product_variants","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_bookmark","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_close_conversation","slack_create_channel_canvas","slack_create_conversation","slack_create_user_group","slack_delete_canvas","slack_delete_file","slack_delete_message","slack_delete_scheduled_message","slack_disable_user_group","slack_download","slack_edit_bookmark","slack_edit_canvas","slack_enable_user_group","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_dnd_info","slack_get_file_info","slack_get_message","slack_get_permalink","slack_get_reactions","slack_get_team_dnd_info","slack_get_team_info","slack_get_team_profile","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_get_user_profile","slack_invite_to_conversation","slack_join_conversation","slack_kick_conversation","slack_leave_conversation","slack_list_bookmarks","slack_list_canvases","slack_list_channels","slack_list_emoji","slack_list_files","slack_list_members","slack_list_pins","slack_list_reactions","slack_list_scheduled_messages","slack_list_user_conversations","slack_list_user_group_members","slack_list_user_groups","slack_list_users","slack_lists_access_delete","slack_lists_access_set","slack_lists_create","slack_lists_download_get","slack_lists_download_start","slack_lists_items_create","slack_lists_items_delete","slack_lists_items_delete_multiple","slack_lists_items_info","slack_lists_items_list","slack_lists_items_update","slack_lists_update","slack_lookup_canvas_sections","slack_lookup_user_by_email","slack_mark_conversation_read","slack_message","slack_message_reader","slack_open_conversation","slack_open_view","slack_pin_message","slack_publish_view","slack_push_view","slack_remove_bookmark","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_revoke_canvas_access","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_set_user_presence","slack_share_canvas","slack_unarchive_conversation","slack_unfurl_links","slack_unpin_message","slack_update_message","slack_update_user_group","slack_update_user_group_members","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_cortex_analyst_ask","snowflake_cortex_analyst_feedback","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_download_file_v2","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_approve_user","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_device_attribute","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_attributes","tailscale_get_device_routes","tailscale_get_dns_configuration","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_get_split_dns","tailscale_get_tailnet_settings","tailscale_get_user","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_restore_user","tailscale_set_acl","tailscale_set_device_attribute","tailscale_set_device_name","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_configuration","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_set_split_dns","tailscale_set_user_role","tailscale_suspend_user","tailscale_update_device_key","tailscale_update_split_dns","tailscale_update_tailnet_settings","tailscale_validate_acl","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_higgsfield","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youcom_answer","youcom_finance_research","youcom_get_account_balance","youcom_get_contents","youcom_get_research_task","youcom_research","youcom_search","youcom_search_images","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_create_account","zoho_desk_create_contact","zoho_desk_create_ticket","zoho_desk_delete_comment","zoho_desk_get_account","zoho_desk_get_agent","zoho_desk_get_attachment","zoho_desk_get_comment","zoho_desk_get_contact","zoho_desk_get_department","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_accounts","zoho_desk_list_agents","zoho_desk_list_comments","zoho_desk_list_contacts","zoho_desk_list_departments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_move_ticket","zoho_desk_search_accounts","zoho_desk_search_contacts","zoho_desk_search_tickets","zoho_desk_send_reply","zoho_desk_update_account","zoho_desk_update_comment","zoho_desk_update_contact","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' ) export default toolIds diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index ff094bc948f..8e1ae7d98e9 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"affinity_batch_update_entity_fields":{"id":"affinity_batch_update_entity_fields","name":"Affinity Batch Update Entity Fields","description":"Write up to 100 non-list field values on one company or person in a single request.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the fields on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_batch_update_list_entry_fields":{"id":"affinity_batch_update_list_entry_fields","name":"Affinity Batch Update List Entry Fields","description":"Write up to 100 field values on one list row in a single request. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_list":{"id":"affinity_create_list","name":"Affinity Create List","description":"Create a list. Its type fixes which entities it can hold, and the API key holder becomes its creator and owner.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the new list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Entity kind the list holds: company, opportunity, or person"},"isPublic":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether everyone in the organization can see the list"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_list_field_dropdown_option":{"id":"affinity_create_list_field_dropdown_option","name":"Affinity Create List Field Dropdown Option","description":"Add a selectable option to a dropdown field on a list. A ranked or status option also needs a rank and a color.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Kind of option to create, matching the field. dropdown takes only a label; ranked-dropdown also requires rank and color; status-dropdown additionally requires a status category. Sending a field the kind does not accept is rejected"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The option label"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_merge":{"id":"affinity_create_merge","name":"Affinity Create Merge","description":"Fold a duplicate company or person into the record you are keeping. The merge runs asynchronously — poll the returned task to see it finish. Requires the \\"Manage duplicates\\" permission and an admin role.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to merge: companies or persons"},"primaryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to keep"},"duplicateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the duplicate record to fold in"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_note":{"id":"affinity_create_note","name":"Affinity Create Note","description":"Write a note — attached to companies, persons, and opportunities, anchored to a meeting, call, or chat message, or posted as a reply to an existing note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Note shape: entities to attach it to records, interaction to anchor it to a meeting, call, or chat message, or user-reply to reply to a note"},"html":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Companies to attach the note to, e.g. [1, 2]. Not used on a reply"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Persons to attach the note to, e.g. [1, 2]. Not used on a reply"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Opportunities to attach the note to, e.g. [1, 2]. Not used on a reply"},"interactionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The interaction to anchor the note to. Required for an interaction note"},"interactionType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Kind of the anchoring interaction: meeting, call, or chat-message. Required for an interaction note"},"parentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The note being replied to. Required for a user-reply note"},"creatorId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Attribute the note to another internal person. Defaults to the API key holder"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdate the note to this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_reminder":{"id":"affinity_create_reminder","name":"Affinity Create Reminder","description":"Create a reminder on one company, person, or opportunity. A recurring reminder resets whenever the chosen signal happens instead of firing once.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"one-time to fire once, or recurring to reset on a signal"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What the reminder is about: company, person, or opportunity"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"dueDate":{"type":"string","required":false,"visibility":"user-or-llm","description":"When the reminder is due, as an ISO 8601 timestamp. Required for a one-time reminder; on a recurring one Affinity computes it from the period when omitted"},"content":{"type":"string","required":false,"visibility":"user-or-llm","description":"What the reminder says"},"ownerId":{"type":"string","required":true,"visibility":"user-or-llm","description":"User the reminder is assigned to. Must be an internal user. The API key holder is recorded as the creator, which is a separate field"},"resetTrigger":{"type":"string","required":false,"visibility":"user-or-llm","description":"What restarts a recurring reminder: interaction, email, or event. Required when the type is recurring"},"periodDays":{"type":"number","required":false,"visibility":"user-or-llm","description":"Days between firings of a recurring reminder. Required when the type is recurring"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_delete_list_field_dropdown_option":{"id":"affinity_delete_list_field_dropdown_option","name":"Affinity Delete List Field Dropdown Option","description":"Permanently delete a dropdown option on a list field. Every list entry currently set to it is cleared, and those values cannot be recovered.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to delete"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_delete_note":{"id":"affinity_delete_note","name":"Affinity Delete Note","description":"Delete a note you created. Deleting a root note also deletes its replies; deleting a reply removes only that reply.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to delete"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_company":{"id":"affinity_get_company","name":"Affinity Get Company","description":"Look up one company by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"companyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The company ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_current_user":{"id":"affinity_get_current_user","name":"Affinity Get Current User","description":"Verify an Affinity API key and return the tenant, the user behind the key, and the scopes the grant carries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_entity_field_value":{"id":"affinity_get_entity_field_value","name":"Affinity Get Entity Field Value","description":"Read one non-list field value from a company or person.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read the field from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list":{"id":"affinity_get_list","name":"Affinity Get List","description":"Read one list — its name, type, owner, and privacy setting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list_entry":{"id":"affinity_get_list_entry","name":"Affinity Get List Entry","description":"Read one row of a list with its entity. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list_entry_field":{"id":"affinity_get_list_entry_field","name":"Affinity Get List Entry Field","description":"Read one field value on a list row.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list_field_dropdown_option":{"id":"affinity_get_list_field_dropdown_option","name":"Affinity Get List Field Dropdown Option","description":"Read one dropdown option on a list field.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_merge":{"id":"affinity_get_merge","name":"Affinity Get Merge","description":"Read the status of one company or person merge, including why it failed if it did.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge to read: companies or persons"},"mergeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_merge_task":{"id":"affinity_get_merge_task","name":"Affinity Get Merge Task","description":"Read one merge task and how its merges are progressing. Poll this after starting a merge.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge task to read: companies or persons"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge task ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_note":{"id":"affinity_get_note","name":"Affinity Get Note","description":"Read one note with its body, author, mentions, and attached records.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_opportunity":{"id":"affinity_get_opportunity","name":"Affinity Get Opportunity","description":"Read one opportunity and the list it belongs to. Its field data lives on the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"opportunityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The opportunity ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_person":{"id":"affinity_get_person","name":"Affinity Get Person","description":"Look up one person by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"personId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The person ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_saved_view":{"id":"affinity_get_saved_view","name":"Affinity Get Saved View","description":"Read one saved view — its name, kind, and creation date.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_transcript":{"id":"affinity_get_transcript","name":"Affinity Get Transcript","description":"Read one transcript with its first 100 fragments. Page the fragments endpoint for a longer meeting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_user":{"id":"affinity_get_user","name":"Affinity Get User","description":"Read one internal user. A user and their person record share the same numeric ID, so a person ID works here.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"userId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The user ID, which is also their person ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_calls":{"id":"affinity_list_calls","name":"Affinity List Calls","description":"Page through logged calls and their participants. Only calls the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_chat_messages":{"id":"affinity_list_chat_messages","name":"Affinity List Chat Messages","description":"Page through logged chat messages and their participants. Only messages the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_companies":{"id":"affinity_list_companies","name":"Affinity List Companies","description":"Page through companies. Companies come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these company IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_coworker_connections":{"id":"affinity_list_coworker_connections","name":"Affinity List Coworker Connections","description":"Find warm paths into a company through shared work history: who in your Affinity data once worked alongside the people you want to reach. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_emails":{"id":"affinity_list_emails","name":"Affinity List Emails","description":"Page through email metadata — subject, participants, and timestamps. Affinity never exposes email bodies through the API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_field_values":{"id":"affinity_list_entity_field_values","name":"Affinity List Entity Field Values","description":"Page through a company\'s or person\'s non-list field values. List fields are not returned here — read those through the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read field values from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_list_entries":{"id":"affinity_list_entity_list_entries","name":"Affinity List Entity List Entries","description":"Page through a company\'s or person\'s rows across every list, each carrying that list\'s field values and when the entity was added.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the rows of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_lists":{"id":"affinity_list_entity_lists","name":"Affinity List Entity Lists","description":"List every list a company or person appears on that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the lists of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_notes":{"id":"affinity_list_entity_notes","name":"Affinity List Entity Notes","description":"List the notes relevant to one company, person, or opportunity — directly attached notes plus notes reaching it through its people and meetings.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity the notes hang off: companies, persons, or opportunities"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_relationships":{"id":"affinity_list_entity_relationships","name":"Affinity List Entity Relationships","description":"List who knows a company or person, scored 0.0 to 1.0 by how much the two actually interact. Strongest first by default.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up relationships for: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on interactionScore only, e.g. \\"interactionScore>=0.5\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"interactionScore\\"] for weakest first, [\\"-interactionScore\\"] for strongest first (the default)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_field_dropdown_options":{"id":"affinity_list_field_dropdown_options","name":"Affinity List Field Dropdown Options","description":"List the selectable options on a dropdown or ranked-dropdown company or person field. Writing such a field needs the option ID, not its text.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which field family the field belongs to: companies or persons"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown or ranked-dropdown field ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_field_metadata":{"id":"affinity_list_field_metadata","name":"Affinity List Field Metadata","description":"List the non-list company or person fields, with the value type, filter operators, and sort support of each. Start here to find the Field IDs the read and write tools take.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which fields to describe: companies or persons"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Status\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_field_value_changes":{"id":"affinity_list_field_value_changes","name":"Affinity List Field Value Changes","description":"Page through field value changes across the whole workspace. Built for delta sync: follow nextCursor to the end of a run, then resume from the last cursor next time.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, listEntry.id, changer.id, changedAt, or actionType. Resume a sync with e.g. \\"changedAt>2026-06-01T12:00:00Z\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"changedAt\\"] for oldest first (the default), [\\"-changedAt\\"] for newest first"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_investor_executive_connections":{"id":"affinity_list_investor_executive_connections","name":"Affinity List Investor Executive Connections","description":"Find warm paths into a company through investment history: which investors in your Affinity data backed a company the people you want to reach once led. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_entries":{"id":"affinity_list_list_entries","name":"Affinity List List Entries","description":"Page through the rows of a list. Rows come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_entry_field_value_changes":{"id":"affinity_list_list_entry_field_value_changes","name":"Affinity List List Entry Field Value Changes","description":"Page through the history of one list row — who changed which field, when, and to what.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, changer.id, changedAt, or actionType, e.g. \\"field.id=field-1234\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_entry_fields":{"id":"affinity_list_list_entry_fields","name":"Affinity List List Entry Fields","description":"Page through every field value on one list row, including the list-specific columns. All fields are returned unless narrowed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, list, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_field_dropdown_options":{"id":"affinity_list_list_field_dropdown_options","name":"Affinity List List Field Dropdown Options","description":"List the selectable options on a dropdown, ranked-dropdown, or status-dropdown field of a list.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_fields":{"id":"affinity_list_list_fields","name":"Affinity List List Fields","description":"List the fields available on one list, including its list-specific columns. Use these Field IDs when reading or writing list entries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Stage\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_lists":{"id":"affinity_list_lists","name":"Affinity List Lists","description":"Page through the lists in the organization that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive substring match on the list name"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_meetings":{"id":"affinity_list_meetings","name":"Affinity List Meetings","description":"Page through past and upcoming meetings with their organizer and attendees.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_merge_tasks":{"id":"affinity_list_merge_tasks","name":"Affinity List Merge Tasks","description":"Page through merge tasks, each summarizing how many of its merges are in progress, succeeded, or failed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge tasks to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on status only, e.g. \\"status=in-progress\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_merges":{"id":"affinity_list_merges","name":"Affinity List Merges","description":"Page through the company or person merges the organization has run, with the status and the records involved in each.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merges to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over status or taskId, e.g. \\"status=failed\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_attached_companies":{"id":"affinity_list_note_attached_companies","name":"Affinity List Note Attached Companies","description":"List the companies directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_attached_opportunities":{"id":"affinity_list_note_attached_opportunities","name":"Affinity List Note Attached Opportunities","description":"List the opportunities directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_attached_persons":{"id":"affinity_list_note_attached_persons","name":"Affinity List Note Attached Persons","description":"List the persons directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_replies":{"id":"affinity_list_note_replies","name":"Affinity List Note Replies","description":"Page through the replies on one note, including AI Notetaker replies.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID whose replies to read"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_notes":{"id":"affinity_list_notes","name":"Affinity List Notes","description":"Page through every note the caller can see. Replies are excluded.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_opportunities":{"id":"affinity_list_opportunities","name":"Affinity List Opportunities","description":"Page through opportunities. Field data lives on the list entry, not here — read it through the list or saved view the opportunity belongs to.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these opportunity IDs, e.g. [1, 2, 3]"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_persons":{"id":"affinity_list_persons","name":"Affinity List Persons","description":"Page through persons. Persons come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these person IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_reminders":{"id":"affinity_list_reminders","name":"Affinity List Reminders","description":"Page through the reminders the caller can see. Filter by status to surface what is overdue.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_saved_view_entries":{"id":"affinity_list_saved_view_entries","name":"Affinity List Saved View Entries","description":"Page through the rows of a saved view. The view\'s own filters and columns decide which rows and which field data come back.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_saved_views":{"id":"affinity_list_saved_views","name":"Affinity List Saved Views","description":"List the saved views on a list that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_transcript_fragments":{"id":"affinity_list_transcript_fragments","name":"Affinity List Transcript Fragments","description":"Page through everything said in a meeting, segment by segment with the speaker.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_transcripts":{"id":"affinity_list_transcripts","name":"Affinity List Transcripts","description":"Page through meeting transcript metadata. Read one transcript to get what was actually said.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_users":{"id":"affinity_list_users","name":"Affinity List Users","description":"Page through the internal users in the organization. Email addresses and roles are returned only to callers with the \\"Manage Users\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive match across first name, last name, and primary email"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over id or status, e.g. \\"status=active\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_companies":{"id":"affinity_search_companies","name":"Affinity Search Companies","description":"Search companies by filters, sorts, and a free-text term. Requires the \\"Export All Organizations directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_files":{"id":"affinity_search_files","name":"Affinity Search Files","description":"Search files by keyword, ordered by relevance. Narrow to specific files or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these file IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s files. Cannot be combined with file IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of files to return, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_list_entries":{"id":"affinity_search_list_entries","name":"Affinity Search List Entries","description":"Search the rows of one list by filters, sorts, and a free-text term. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID to search"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_notes":{"id":"affinity_search_notes","name":"Affinity Search Notes","description":"Search notes by keyword, ordered by relevance. Narrow to specific notes or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these note IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s notes. Cannot be combined with note IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of notes to return, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_persons":{"id":"affinity_search_persons","name":"Affinity Search Persons","description":"Search persons by filters, sorts, and a free-text term. Requires the \\"Export All People directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_semantic_search":{"id":"affinity_semantic_search","name":"Affinity Semantic Search","description":"Find companies from a description in plain language — industry, technology, stage, or business model. Currently searches companies only.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to look for, in plain language, e.g. \\"climate tech companies in our pipeline\\". Up to 500 characters"},"listIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to companies on these lists, e.g. [1, 2]"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of companies to return, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_entity_field_value":{"id":"affinity_update_entity_field_value","name":"Affinity Update Entity Field Value","description":"Write one non-list field value on a company or person. The value type must match how the field is defined.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the field on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_list_entry_field":{"id":"affinity_update_list_entry_field","name":"Affinity Update List Entry Field","description":"Write one field value on a list row. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_list_field_dropdown_option":{"id":"affinity_update_list_field_dropdown_option","name":"Affinity Update List Field Dropdown Option","description":"Change a dropdown option on a list field. Every field is optional — supply only what should change, and only fields the option\'s kind actually has.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to update"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement option label. Supply at least one field to change"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_note":{"id":"affinity_update_note","name":"Affinity Update Note","description":"Rewrite a note\'s body or replace which records it is attached to. Each list of IDs replaces that association wholesale, an empty list clears it, and omitting one leaves it untouched. A note\'s type cannot be changed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to update"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached companies, e.g. [1, 2]. Send [] to detach every company; omit to leave them unchanged"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached persons, e.g. [1, 2]. Send [] to detach every person; omit to leave them unchanged"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached opportunities, e.g. [1, 2]. Send [] to detach every opportunity; omit to leave them unchanged"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}},"hostedApiKey":"none"},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}},"hostedApiKey":"none"},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}},"hostedApiKey":"none"},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}},"hostedApiKey":"none"},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}},"hostedApiKey":"none"},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}},"hostedApiKey":"none"},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}},"hostedApiKey":"none"},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}},"hostedApiKey":"none"},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}},"hostedApiKey":"none"},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}},"hostedApiKey":"none"},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}},"hostedApiKey":"none"},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}},"hostedApiKey":"none"},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}},"hostedApiKey":"none"},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}},"hostedApiKey":"none"},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}},"hostedApiKey":"none"},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}},"hostedApiKey":"none"},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}},"hostedApiKey":"none"},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}},"hostedApiKey":"none"},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"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"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}},"hostedApiKey":"none"},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Start an APIFY Actor run and poll it within the same workflow step until completion or the polling deadline","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\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ], \\"maxPages\\": 10 }"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ] }"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}},"hostedApiKey":"none"},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}},"hostedApiKey":"none"},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}},"hostedApiKey":"none"},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Remove personally identifiable information from an Ashby candidate while retaining the candidate record and its applications. This operation does not delete the candidate. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"},"archiveEmail":{"type":"json","required":false,"visibility":"user-or-llm","description":"Archive email configuration with communicationTemplateId and optional sendAt ISO 8601 timestamp. Pass null or omit to send no archive email."}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in, or FirstPreInterviewScreen (defaults to the first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"},"applicationHistory":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional documented application history entries to create with the application"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"},"location":{"type":"json","required":false,"visibility":"user-or-llm","description":"Candidate location object with optional city, region, and country"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,