diff --git a/apps/desktop/e2e/browser-tools.spec.ts b/apps/desktop/e2e/browser-tools.spec.ts new file mode 100644 index 00000000000..55e8c7021de --- /dev/null +++ b/apps/desktop/e2e/browser-tools.spec.ts @@ -0,0 +1,244 @@ +import { mkdtempSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + type ElectronApplication, + _electron as electron, + expect, + type Page, + test, +} from '@playwright/test' +import type { BrowserToolName } from '@sim/browser-protocol' +import type { SimDesktopApi } from '@sim/desktop-bridge' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SCOPE = 'browser-tools-e2e' +const FORM = `Form fixture + + + + + +
+
Wide content
+
+` + +test.describe('browser tools', () => { + const calls = new Map< + string, + { chatId: string; toolName: BrowserToolName; args: Record } + >() + let server: Server + let origin: string + let app: ElectronApplication + let window: Page + let callCount = 0 + + test.beforeAll(async () => { + server = createServer(async (request, response) => { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + if (path === '/api/desktop/tool/authorize') { + let body = '' + for await (const chunk of request) body += chunk.toString() + const authorization = calls.get(JSON.parse(body).toolCallId) + response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(authorization ?? {})) + return + } + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.end( + path === '/form' + ? FORM + : 'Sim fixture

Browser tools fixture

' + ) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture address') + origin = `http://127.0.0.1:${address.port}` + }) + + test.beforeEach(async () => { + app = await electron.launch({ + args: ['.'], + cwd: DESKTOP_DIR, + env: { + ...process.env, + SIM_DESKTOP_ORIGIN: origin, + SIM_DESKTOP_USER_DATA: mkdtempSync(join(tmpdir(), 'sim-browser-tools-e2e-')), + }, + }) + window = await app.firstWindow() + await expect(window.getByRole('heading')).toHaveText('Browser tools fixture') + await window.evaluate(async (scope) => { + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + await api.browserAgent.activateScope(scope) + api.browserAgent.setPanelBounds( + { x: 0, y: 80, width: innerWidth, height: innerHeight - 80 }, + null, + scope + ) + }, SCOPE) + }) + + test.afterEach(async () => { + await app?.close() + calls.clear() + }) + + test.afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + }) + + async function execute(tool: BrowserToolName, args: Record) { + const callId = `browser-fixture-${++callCount}` + calls.set(callId, { chatId: SCOPE, toolName: tool, args }) + return window.evaluate( + async ({ callId, tool, args, scope }) => { + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + return api.browserAgent.executeTool(callId, tool, args, scope) + }, + { callId, tool, args, scope: SCOPE } + ) + } + + async function openForm() { + const response = await execute('browser_open_url', { url: `${origin}/form` }) + expect(response.ok, response.error).toBe(true) + const result = response.result as { snapshot: { outline: string } } + expect(result.snapshot.outline).toContain('Name') + return (name: string) => { + const line = result.snapshot.outline.split('\n').find((line) => line.includes(`"${name}"`)) + const match = line?.match(/\[ref=(\d+)\]/) + if (!match) throw new Error(`No reference for ${name}: ${result.snapshot.outline}`) + return Number(match[1]) + } + } + + async function formState() { + return app.evaluate(async ({ webContents }, origin) => { + const page = webContents + .getAllWebContents() + .find((contents) => contents.getURL().startsWith(`${origin}/form`)) + if (!page) throw new Error('Missing browser fixture') + return page.executeJavaScript(`({ + name: document.getElementById('name').value, + plan: document.getElementById('plan').value, + updates: document.getElementById('updates').checked, + password: document.getElementById('password').value, + route: document.getElementById('route').value, + scrollLeft: document.getElementById('horizontal').scrollLeft + })`) + }, origin) + } + + test('opens with references, fills in order, and scrolls a horizontal pane', async () => { + const ref = await openForm() + const fill = await execute('browser_fill_form', { + fields: [ + { elementId: ref('Name'), kind: 'text', text: 'Example User' }, + { elementId: ref('Plan'), kind: 'select', value: 'pro' }, + { elementId: ref('Updates'), kind: 'checked', checked: true }, + ], + }) + expect(fill.ok, fill.error).toBe(true) + expect(fill.result, JSON.stringify(fill.result)).toMatchObject({ + completed: true, + completedCount: 3, + }) + expect(await formState()).toMatchObject({ name: 'Example User', plan: 'pro', updates: true }) + const cleared = await execute('browser_fill_form', { + fields: [{ elementId: ref('Name'), kind: 'text', text: '' }], + }) + expect(cleared.result, JSON.stringify(cleared.result)).toMatchObject({ completed: true }) + expect(await formState()).toMatchObject({ name: '' }) + + const scroll = await execute('browser_scroll', { + direction: 'right', + amount: 240, + elementId: ref('Wide table'), + }) + expect(scroll.ok, scroll.error).toBe(true) + expect(scroll.result).toMatchObject({ movedBy: 240 }) + expect(Math.round((await formState()).scrollLeft)).toBe(240) + await execute('browser_scroll', { + direction: 'left', + amount: 240, + elementId: ref('Wide table'), + }) + expect(await formState()).toMatchObject({ scrollLeft: 0 }) + }) + + test('stops after a route change without writing the next field', async () => { + const ref = await openForm() + const fill = await execute('browser_fill_form', { + fields: [ + { elementId: ref('Route'), kind: 'text', text: 'change route' }, + { elementId: ref('Name'), kind: 'text', text: 'Must not be written' }, + ], + }) + expect(fill.result, JSON.stringify(fill.result)).toMatchObject({ + completed: false, + doNotRetry: true, + }) + expect(await formState()).toMatchObject({ name: '', route: 'change route' }) + }) + + test('stops when a new popup exceeds the page summary limit', async () => { + const ref = await openForm() + await app.evaluate(async ({ webContents }, origin) => { + const page = webContents + .getAllWebContents() + .find((contents) => contents.getURL().startsWith(`${origin}/form`)) + if (!page) throw new Error('Missing browser fixture') + await page.executeJavaScript(` + for (let index = 0; index < 10; index++) { + const toolbar = document.createElement('div') + toolbar.setAttribute('role', 'toolbar') + toolbar.textContent = 'Toolbar ' + index + document.body.append(toolbar) + } + document.getElementById('name').addEventListener('input', () => { + const popup = document.createElement('div') + popup.setAttribute('role', 'listbox') + popup.textContent = 'Suggestions' + document.body.append(popup) + }, { once: true }) + `) + }, origin) + + const fill = await execute('browser_fill_form', { + fields: [ + { elementId: ref('Name'), kind: 'text', text: 'Example User' }, + { elementId: ref('Plan'), kind: 'select', value: 'pro' }, + ], + }) + expect(fill.ok, fill.error).toBe(true) + expect(fill.result, JSON.stringify(fill.result)).toMatchObject({ + completed: false, + completedCount: 1, + stoppedIndex: 0, + results: [{ verified: true, valuePreview: 'Example User' }], + doNotRetry: true, + error: expect.stringContaining('could not be fully verified'), + }) + expect(await formState()).toMatchObject({ name: 'Example User', plan: 'basic' }) + }) + + test('refuses credential fields and leaves subsequent fields untouched', async () => { + const ref = await openForm() + const fill = await execute('browser_fill_form', { + fields: [ + { elementId: ref('Password'), kind: 'text', text: 'must-not-be-entered' }, + { elementId: ref('Name'), kind: 'text', text: 'Must not be written' }, + ], + }) + expect(fill.result).toMatchObject({ completed: false, completedCount: 0 }) + expect(await formState()).toMatchObject({ name: '', password: '' }) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index c5e6a85dd4c..5c268072838 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -111,6 +111,64 @@ describe('executeTool', () => { expect(grant).toHaveBeenCalledTimes(navigations.length) }) + it('keeps the 400ms hydration grace without rediscovering a completed load', async () => { + await driver.executeTool('chat-test', 'browser_open_tab', {}) + const contents = session.requireTab().view.webContents + vi.useFakeTimers() + try { + let settled = false + const navigation = driver.executeTool('chat-test', 'browser_navigate', { + url: 'http://127.0.0.1/loaded', + }) + void navigation.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(0) + expect(contents.loadURL).toHaveBeenCalledWith('http://127.0.0.1/loaded') + + await vi.advanceTimersByTimeAsync(399) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(true) + await expect(navigation).resolves.toMatchObject({ ok: true }) + } finally { + vi.useRealTimers() + } + }) + + it('still waits for a replacement load after loadURL has resolved', async () => { + await driver.executeTool('chat-test', 'browser_open_tab', {}) + const contents = session.requireTab().view.webContents + vi.mocked(contents.isLoading).mockReturnValue(true) + vi.useFakeTimers() + try { + let settled = false + const navigation = driver.executeTool('chat-test', 'browser_navigate', { + url: 'http://127.0.0.1/loading', + }) + void navigation.then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(500) + expect(settled).toBe(false) + vi.mocked(contents.isLoading).mockReturnValue(false) + for (const [event, listener] of vi.mocked(contents.on).mock.calls) { + if (String(event) === 'did-stop-loading') { + const onLoadComplete = listener as (...args: unknown[]) => void + onLoadComplete() + } + } + await vi.advanceTimersByTimeAsync(399) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(true) + await expect(navigation).resolves.toMatchObject({ ok: true }) + } finally { + vi.useRealTimers() + } + }) + it('reports an aborted navigation when Chromium never leaves the current URL', async () => { vi.useFakeTimers() try { @@ -2013,6 +2071,210 @@ describe('credential protection', () => { .mock.calls.filter(([called]) => called === method) } + async function openForm( + options: { + refuseAt?: number + retainValue?: boolean + afterWrite?: (index: number) => void + waitForWrite?: Promise + } = {} + ) { + const contents = await openPage() + const values = ['', ''] + const writes: number[] = [] + const dialogs: string[] = [] + let selectionReads = 0 + vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => { + const encoded = expression.match(/\.apply\(null, (\[[^\n]*\])\)/)?.[1] + const args: unknown[] = encoded ? JSON.parse(encoded) : [] + const index = Number(args[0]) - 1 + if (isPageCall(expression, 'collectSnapshot')) + return { + url: 'https://example.com/login', + title: 'Form', + outline: '- combobox "First" [ref=1]\n- combobox "Second" [ref=2]', + truncated: false, + refIds: [1, 2], + refLineIndexes: { 1: 0, 2: 1 }, + nextElementId: 3, + } + if (isPageCall(expression, 'readPageActionState')) + return { + url: contents.getURL(), + dialogs: [...dialogs], + popups: [], + observationTruncated: false, + } + if (isPageCall(expression, 'readFormFieldState')) { + if (index === options.refuseAt) return { error: 'password' } + return { + matchesRequested: values[index] === args[2], + valueLength: values[index].length, + valuePreview: values[index], + redacted: false, + } + } + if (isPageCall(expression, 'clickElement')) + return { dispatched: false, x: 24, y: 48, element: 'Select' } + if (isPageCall(expression, 'selectOptionInElement')) { + writes.push(index) + await options.waitForWrite + const requested = String(args[1]) + if (options.retainValue !== false) values[index] = requested + options.afterWrite?.(index) + return { selected: requested, value: requested } + } + if (isPageCall(expression, 'readSelectElementState')) { + selectionReads++ + return { selected: values[index], value: values[index] } + } + return undefined + }) + const snapshot = await driver.executeTool('chat-test', 'browser_snapshot', {}) + expect(snapshot, JSON.stringify(snapshot)).toMatchObject({ ok: true }) + return { contents, values, writes, dialogs, selectionReads: () => selectionReads } + } + + const formFields = [ + { elementId: 1, kind: 'select', value: 'first' }, + { elementId: 2, kind: 'select', value: 'second' }, + ] + + it('fills known form fields in order and verifies every final value', async () => { + const form = await openForm() + const result = await driver.executeTool('chat-test', 'browser_fill_form', { + fields: formFields, + }) + expect(result.result, JSON.stringify(result)).toMatchObject({ completed: true }) + expect(form.writes).toEqual([0, 1]) + expect(result).toMatchObject({ + ok: true, + result: { + completed: true, + completedCount: 2, + results: [ + { index: 0, verified: true, valuePreview: 'first' }, + { index: 1, verified: true, valuePreview: 'second' }, + ], + }, + }) + }) + + it.each([ + { fields: [] }, + { + fields: Array.from({ length: 9 }, (_, elementId) => ({ elementId, kind: 'text', text: 'x' })), + }, + { fields: [formFields[0], { ...formFields[1], submit: true }] }, + { fields: [formFields[0], formFields[0]] }, + { fields: [{ elementId: 0, kind: 'text', text: 'x'.repeat(4097) }] }, + ])('validates the entire bounded form payload before writing', async (params) => { + const form = await openForm() + expect(await driver.executeTool('chat-test', 'browser_fill_form', params)).toMatchObject({ + ok: false, + }) + expect(form.writes).toEqual([]) + }) + + it('preflights later secret fields before changing earlier fields', async () => { + const form = await openForm({ refuseAt: 1 }) + const result = await driver.executeTool('chat-test', 'browser_fill_form', { + fields: formFields, + }) + expect(form.writes).toEqual([]) + expect(result).toMatchObject({ + ok: true, + result: { completed: false, completedCount: 0, stoppedIndex: 1 }, + }) + }) + + it('does not mistake dispatch or weak effects for a retained requested value', async () => { + const form = await openForm({ retainValue: false }) + const result = await driver.executeTool('chat-test', 'browser_fill_form', { + fields: formFields, + }) + expect(form.writes).toEqual([0]) + expect(result).toMatchObject({ + ok: true, + result: { + completed: false, + completedCount: 0, + stoppedIndex: 0, + results: [{ verified: false }], + doNotRetry: true, + }, + }) + }) + + it('returns verified partial results and skips later fields when a dialog opens', async () => { + const form: Awaited> = await openForm({ + afterWrite: () => form.dialogs.push('Confirm'), + }) + const result = await driver.executeTool('chat-test', 'browser_fill_form', { + fields: formFields, + }) + expect(form.writes).toEqual([0]) + expect(result).toMatchObject({ + ok: true, + result: { + completed: false, + completedCount: 1, + results: [{ verified: true }], + doNotRetry: true, + }, + }) + }) + + it('stops before the next field after same-document navigation', async () => { + const form: Awaited> = await openForm({ + afterWrite: () => vi.mocked(form.contents.getURL).mockReturnValue('https://example.com/next'), + }) + const result = await driver.executeTool('chat-test', 'browser_fill_form', { + fields: formFields, + }) + expect(form.writes).toEqual([0]) + expect(result).toMatchObject({ ok: true, result: { completed: false, doNotRetry: true } }) + }) + + it('detects a later field changing an earlier completed field', async () => { + const form: Awaited> = await openForm({ + afterWrite: (index) => { + if (index === 1) form.values[0] = 'changed' + }, + }) + const result = await driver.executeTool('chat-test', 'browser_fill_form', { + fields: formFields, + }) + expect(result).toMatchObject({ + ok: true, + result: { + completed: false, + stoppedIndex: 0, + results: [{ verified: false }, { verified: true }], + }, + }) + }) + + it('prevents later form writes after cancellation even if the pending page call resolves late', async () => { + let releaseWrite: () => void = () => {} + const waitForWrite = new Promise((resolve) => { + releaseWrite = resolve + }) + const form = await openForm({ waitForWrite }) + const pending = driver.executeTool( + 'chat-test', + 'browser_fill_form', + { fields: formFields }, + 'cancel-form' + ) + await vi.waitFor(() => expect(form.writes).toEqual([0])) + driver.cancelTool('chat-test', 'cancel-form') + expect(await pending).toMatchObject({ ok: false, error: expect.stringContaining('cancelled') }) + releaseWrite() + await vi.waitFor(() => expect(form.selectionReads()).toBe(1)) + expect(form.writes).toEqual([0]) + }) + function mockScreenshotImage(size: { width: number; height: number } | null): void { vi.mocked(nativeImage.createFromBuffer).mockReturnValueOnce({ isEmpty: vi.fn(() => size === null), @@ -2200,6 +2462,35 @@ describe('credential protection', () => { expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1) }) + it('accepts empty text and sends it through native insertion to clear a field', async () => { + const contents = await openPage() + respondWith(contents, { + focusElementForTyping: { focused: true, kind: 'input', x: 24, y: 48 }, + readActiveElementState: { activeElement: 'input', valueLength: 0, valuePreview: '' }, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_type', { elementId: 0, text: '' }) + + expect(result).toMatchObject({ ok: true, result: { dispatched: true, trusted: true } }) + expect(cdpCalls(contents, 'Input.insertText')).toEqual([['Input.insertText', { text: '' }]]) + }) + + it.each([{}, { text: undefined }, { text: null }, { text: 7 }, { text: false }])( + 'rejects missing or nonstring text before native input', + async (params) => { + const contents = await openPage() + const result = await driver.executeTool('chat-test', 'browser_type', { + elementId: 0, + ...params, + }) + + expect(result).toMatchObject({ ok: false, error: expect.stringContaining('text') }) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0) + } + ) + it('types through a focused combobox suggestions popup without pointer probing', async () => { const contents = await openPage() respondWith(contents, { @@ -2406,7 +2697,7 @@ describe('credential protection', () => { expect(result).toMatchObject({ ok: false, - error: 'Scroll direction must be "up" or "down".', + error: 'Scroll direction must be "up", "down", "left", or "right".', }) expect( vi @@ -2415,6 +2706,35 @@ describe('credential protection', () => { ).toBe(false) }) + it.each(['left', 'right'])( + 'accepts browser_scroll %s and returns horizontal movement', + async (direction) => { + const contents = await openPage() + const movedBy = direction === 'left' ? -100 : 100 + respondWith(contents, { + scrollPage: { + target: 'Table columns', + targetSource: 'viewport-center', + movedBy, + scrollLeft: 300, + scrollWidth: 1_000, + clientWidth: 200, + atLeft: false, + atRight: false, + atTop: true, + atBottom: true, + }, + }) + + expect( + await driver.executeTool('chat-test', 'browser_scroll', { direction, amount: 100 }) + ).toMatchObject({ + ok: true, + result: { movedBy, scrollLeft: 300, atLeft: false, atRight: false }, + }) + } + ) + it('confirms a click when the requested target changes semantic state', async () => { const contents = await openPage() let actionReads = 0 diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 671b189bbfb..62f3fda318d 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -60,6 +60,7 @@ import { readActiveElementState, readCheckableElementState, readChildFrameElementState, + readFormFieldState, readPageActionState, readPageText, readSelectElementState, @@ -83,6 +84,9 @@ const TAKEOVER_POLL_MS = 1_500 * legitimate tool (browser_wait_for caps at 120s). */ const DEFAULT_TOOL_WATCHDOG_MS = 20_000 +const MAX_FORM_FIELDS = 8 +const MAX_FORM_FIELD_TEXT = 4_096 +const MAX_FORM_TEXT = 16_384 const WAIT_FOR_TOOL_WATCHDOG_GRACE_MS = 5_000 /** Retained native tool calls: generous for normal serial use, finite under a wedged caller. */ export const BROWSER_TOOL_ADMISSION_LIMITS = Object.freeze({ @@ -117,6 +121,73 @@ function isBrowserWaitElementState(value: string): value is BrowserWaitElementSt type PageExecutionTarget = WebContents | WebFrameMain +type FormField = + | { elementId: number; kind: 'text'; text: string } + | { elementId: number; kind: 'select'; value: string } + | { elementId: number; kind: 'checked'; checked: boolean } + +function parseFormFields(params: Record): FormField[] { + if (Object.keys(params).some((key) => key !== 'fields')) { + throw new ToolError('Form filling accepts only fields; submitting is not supported.') + } + if ( + !Array.isArray(params.fields) || + params.fields.length === 0 || + params.fields.length > MAX_FORM_FIELDS + ) { + throw new ToolError(`Form filling requires between 1 and ${MAX_FORM_FIELDS} fields.`) + } + const ids = new Set() + let totalText = 0 + return params.fields.map((field): FormField => { + if ( + !isRecordLike(field) || + typeof field.elementId !== 'number' || + !Number.isSafeInteger(field.elementId) || + field.elementId < 0 || + ids.has(field.elementId) + ) { + throw new ToolError('Every form field requires a unique nonnegative integer elementId.') + } + ids.add(field.elementId) + const valueKey = + field.kind === 'text' + ? 'text' + : field.kind === 'select' + ? 'value' + : field.kind === 'checked' + ? 'checked' + : null + if ( + !valueKey || + Object.keys(field).some((key) => !['elementId', 'kind', valueKey].includes(key)) + ) { + throw new ToolError( + 'Each form field must specify text, select, or checked and only its matching value parameter.' + ) + } + if (field.kind === 'checked' && typeof field.checked === 'boolean') { + return { elementId: field.elementId, kind: 'checked', checked: field.checked } + } + const value = field[valueKey] + if ( + typeof value !== 'string' || + value.length > MAX_FORM_FIELD_TEXT || + field.kind === 'checked' + ) { + throw new ToolError( + `Text and selection values must be strings of at most ${MAX_FORM_FIELD_TEXT} characters; checked must be boolean.` + ) + } + totalText += value.length + if (totalText > MAX_FORM_TEXT) + throw new ToolError(`Form field text cannot exceed ${MAX_FORM_TEXT} characters in total.`) + return field.kind === 'text' + ? { elementId: field.elementId, kind: 'text', text: value } + : { elementId: field.elementId, kind: 'select', value } + }) +} + export type BrowserSessionPersistence = session.BrowserSessionPersistence export interface DriverCallbacks { @@ -1303,8 +1374,10 @@ async function loadAgentCheckedUrlAndGetResult( throw new ToolError('The tab was closed before navigation could start.') } const beforeUrl = contents.getURL() + let loadCompleted = false try { await contents.loadURL(url) + loadCompleted = true } catch (error) { const candidate = error as { code?: unknown; errno?: unknown } const routineAbort = @@ -1322,7 +1395,10 @@ async function loadAgentCheckedUrlAndGetResult( throw new ToolError(`The navigation was aborted (${getErrorMessage(error)}).`) } } - return await navigationResult(contents) + return await navigationResult( + contents, + loadCompleted && !contents.isLoading() ? Promise.resolve() : undefined + ) } /** @@ -3057,9 +3133,185 @@ async function executeToolInner( } } + case 'browser_fill_form': { + const fields = parseFormFields(params) + const contents = session.requireAutomationTab().view.webContents + const epoch = navigationEpoch(contents) + const url = contents.getURL() + const state = driverScopeState() + const tabIds = session + .getTabsState() + .tabs.map((tab) => tab.tabId) + .join(',') + const downloadIds = session + .getBrowserDownloadsState(session.getBrowserScopeId()) + .downloads.map((download) => download.id) + .join(',') + const noticeCount = state.pendingNotices.length + const deadline = Math.min(executionDeadline ?? Number.POSITIVE_INFINITY, Date.now() + 18_000) + const results: Record[] = [] + let stoppedIndex = 0 + let dispatchStarted = false + const readField = async (field: FormField) => { + const target = pageTargetForElement(contents, field.elementId) + if (target !== contents) + throw new ToolError( + 'Form batches require top-page fields; use individual tools for framed fields.' + ) + const readback = toRecord( + unwrapPageResult( + await execInPage( + contents, + readFormFieldState, + [ + field.elementId, + field.kind, + field.kind === 'text' + ? field.text + : field.kind === 'select' + ? field.value + : field.checked, + ], + false, + deadline + ) + ) + ) + if (typeof readback.error === 'string') throw new ToolError(readback.error) + if (typeof readback.matchesRequested !== 'boolean') + throw new ToolError('The form field could not be verified.') + return readback + } + const readBoundary = async () => { + const boundary = toRecord( + await execInPage(contents, readPageActionState, [], false, deadline) + ) + if ( + !Array.isArray(boundary.dialogs) || + !Array.isArray(boundary.popups) || + boundary.observationTruncated === true + ) { + throw new ToolError( + 'The page state could not be fully verified for form filling. Use individual field tools.' + ) + } + return JSON.stringify([boundary.url, boundary.dialogs, boundary.popups]) + } + const assertBoundary = () => { + assertCurrentExecution() + if (Date.now() >= deadline) throw new ToolError('Form filling reached its time limit.') + assertActiveContents(contents, epoch) + if ( + contents.getURL() !== url || + session + .getTabsState() + .tabs.map((tab) => tab.tabId) + .join(',') !== tabIds || + state.pendingNotices.length !== noticeCount || + session + .getBrowserDownloadsState(session.getBrowserScopeId()) + .downloads.map((download) => download.id) + .join(',') !== downloadIds + ) { + throw new ToolError( + 'The page, tabs, dialogs, or downloads changed during form filling. Inspect the page before continuing.' + ) + } + } + try { + assertBoundary() + const initialBoundary = await readBoundary() + for (const [index, field] of fields.entries()) { + stoppedIndex = index + await readField(field) + assertBoundary() + } + for (const [index, field] of fields.entries()) { + stoppedIndex = index + assertBoundary() + if ((await readBoundary()) !== initialBoundary) + throw new ToolError('A dialog, popup, or page transition interrupted form filling.') + const before = await readField(field) + assertBoundary() + if (before.matchesRequested !== true) { + dispatchStarted = true + await executeToolInner( + field.kind === 'text' + ? 'browser_type' + : field.kind === 'select' + ? 'browser_select_option' + : 'browser_set_checked', + field.kind === 'text' + ? { elementId: field.elementId, text: field.text } + : field.kind === 'select' + ? { elementId: field.elementId, value: field.value } + : { elementId: field.elementId, checked: field.checked }, + assertBoundary, + deadline, + invocationEpoch + ) + } + assertBoundary() + const readback = await readField(field) + results.push({ + index, + elementId: field.elementId, + kind: field.kind, + verified: readback.matchesRequested === true, + ...omit(readback, ['matchesRequested', 'focused']), + }) + if (readback.matchesRequested !== true) + throw new ToolError( + 'The field did not retain the requested value. Inspect its readback before continuing.' + ) + if ( + field.kind === 'text' && + before.matchesRequested !== true && + readback.focused !== true + ) + throw new ToolError( + 'Focus moved away from the typed field. Inspect the page before continuing.' + ) + if ((await readBoundary()) !== initialBoundary) + throw new ToolError('A dialog, popup, or page transition interrupted form filling.') + assertBoundary() + } + for (const [index, field] of fields.entries()) { + stoppedIndex = index + const readback = await readField(field) + results[index] = { + ...results[index], + verified: readback.matchesRequested === true, + ...omit(readback, ['matchesRequested', 'focused']), + } + assertBoundary() + if (readback.matchesRequested !== true) + throw new ToolError( + 'A previously filled field changed. Inspect the partial result before continuing.' + ) + } + if ((await readBoundary()) !== initialBoundary) + throw new ToolError('A dialog, popup, or page transition interrupted form filling.') + assertBoundary() + return { completed: true, completedCount: fields.length, results } + } catch (error) { + assertCurrentExecution() + return { + completed: false, + completedCount: results.filter((result) => result.verified === true).length, + stoppedIndex, + results, + error: getErrorMessage(error), + doNotRetry: dispatchStarted, + note: 'Earlier fields may already have taken effect. Inspect the readbacks and take a fresh snapshot before deciding which remaining fields to fill. Form filling is not atomic.', + } + } + } + case 'browser_type': { const elementId = requireNum(params, 'elementId') - const text = requireStr(params, 'text') + const text = params.text + if (typeof text !== 'string') throw new ToolError('Missing required parameter "text"') const submit = params.submit === true const contents = session.requireAutomationTab().view.webContents const target = pageTargetForElement(contents, elementId) @@ -3558,8 +3810,8 @@ async function executeToolInner( case 'browser_scroll': { const direction = requireStr(params, 'direction') - if (direction !== 'up' && direction !== 'down') { - throw new ToolError('Scroll direction must be "up" or "down".') + if (!['up', 'down', 'left', 'right'].includes(direction)) { + throw new ToolError('Scroll direction must be "up", "down", "left", or "right".') } const contents = session.requireAutomationTab().view.webContents const elementId = num(params, 'elementId') diff --git a/apps/desktop/src/main/browser-agent/form-fields.test.ts b/apps/desktop/src/main/browser-agent/form-fields.test.ts new file mode 100644 index 00000000000..aba65ed5d7b --- /dev/null +++ b/apps/desktop/src/main/browser-agent/form-fields.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it } from 'vitest' +import { readFormFieldState } from '@/main/browser-agent/page-functions' + +describe('readFormFieldState', () => { + beforeEach(() => { + document.body.innerHTML = '' + window.__simAgentResolveElement = undefined + window.__simAgentElements = [] + }) + + function register(markup: string): HTMLInputElement { + document.body.innerHTML = markup + const element = document.body.firstElementChild as HTMLInputElement + window.__simAgentElements = [element] + return element + } + + it('compares the complete actual value even when previews and lengths match', () => { + const input = register('') + input.value = `${'x'.repeat(120)}actual` + expect(readFormFieldState(0, 'text', `${'x'.repeat(120)}wanted`)).toMatchObject({ + matchesRequested: false, + valueLength: 126, + valuePreview: 'x'.repeat(120), + }) + expect(readFormFieldState(0, 'text', input.value)).toMatchObject({ matchesRequested: true }) + }) + + it.each([ + 'type="password"', + 'autocomplete="section-login current-password"', + 'autocomplete="new-password"', + ])('refuses credentials without a value readback (%s)', (attributes) => { + register(``) + expect(readFormFieldState(0, 'text', 'secret')).toEqual({ error: 'password' }) + }) + + it.each(['one-time-code', 'cc-number', 'cc-csc', 'cc-exp'])( + 'verifies %s without previewing it', + (hint) => { + register(``) + expect(readFormFieldState(0, 'text', '123456')).toMatchObject({ + matchesRequested: true, + redacted: true, + valueLength: 6, + valuePreview: '', + }) + } + ) + + it.each(['', '
editor
'])( + 'refuses nonordinary text fields', + (markup) => { + register(markup) + expect(readFormFieldState(0, 'text', '')).toEqual({ + error: 'Form batches require ordinary text inputs or textareas.', + }) + } + ) + + it('rejects a same-origin framed field', () => { + document.body.innerHTML = '' + const inner = document.querySelector('iframe')?.contentDocument + if (!inner) throw new Error('Missing test frame') + inner.body.innerHTML = '' + window.__simAgentElements = [inner.body.firstElementChild as Element] + expect(readFormFieldState(0, 'text', '')).toEqual({ + error: 'Form batches require top-page fields.', + }) + }) + + it('verifies native selection by the existing case-insensitive value or label match', () => { + register( + '' + ) + expect(readFormFieldState(0, 'select', 'UNITED STATES')).toMatchObject({ + matchesRequested: true, + valuePreview: 'us', + }) + expect(readFormFieldState(0, 'select', 'Canada')).toMatchObject({ matchesRequested: false }) + }) + + it('rejects disabled options and multi-select controls', () => { + register('') + expect(readFormFieldState(0, 'select', 'us')).toMatchObject({ error: expect.any(String) }) + register('') + expect(readFormFieldState(0, 'select', 'us')).toMatchObject({ error: expect.any(String) }) + }) + + it('verifies native checkbox state while refusing direct radio unchecks', () => { + register('') + expect(readFormFieldState(0, 'checked', true)).toEqual({ + matchesRequested: true, + checked: true, + }) + register('') + expect(readFormFieldState(0, 'checked', false)).toMatchObject({ error: expect.any(String) }) + }) +}) 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 80f3bc4af36..f5b3148ba3c 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -915,6 +915,43 @@ describe('collectSnapshot', () => { expect(after.popups).not.toEqual(before.popups) }) + it.each([ + { role: 'dialog', field: 'dialogs' }, + { role: 'toolbar', field: 'popups' }, + ] as const)( + 'reports truncation when visible $field exceed the summary limit', + ({ role, field }) => { + for (let index = 0; index < 10; index++) { + const element = visible(document.createElement('div')) + element.setAttribute('role', role) + element.setAttribute('aria-label', `Existing ${index}`) + document.body.append(element) + } + const before = readPageActionState() as { + dialogs: string[] + popups: string[] + observationTruncated: boolean + } + expect(before[field]).toHaveLength(10) + expect(before.observationTruncated).toBe(false) + + const additional = visible(document.createElement('div')) + additional.setAttribute('role', role === 'toolbar' ? 'listbox' : role) + additional.setAttribute('aria-label', 'New overlay') + document.body.append(additional) + expect(readPageActionState()).toMatchObject({ + [field]: before[field], + observationTruncated: true, + }) + + additional.setAttribute('aria-hidden', 'true') + expect(readPageActionState()).toMatchObject({ + [field]: before[field], + observationTruncated: false, + }) + } + ) + it('reports a targeted control semantic disappearance after its panel closes', () => { document.body.innerHTML = ` @@ -1276,6 +1313,163 @@ describe('scrollPage', () => { return { scroller, child } } + function makeHorizontalScroller( + scrollLeft = 0, + rtl = false + ): { + scroller: HTMLDivElement + child: HTMLDivElement + } { + const { scroller, child } = makeScroller(300) + scroller.style.overflowX = 'auto' + scroller.style.direction = rtl ? 'rtl' : 'ltr' + Object.defineProperties(scroller, { + clientWidth: { configurable: true, value: 200 }, + scrollWidth: { configurable: true, value: 1_000 }, + scrollLeft: { configurable: true, writable: true, value: scrollLeft }, + }) + Object.defineProperty(scroller, 'scrollBy', { + configurable: true, + value: ({ left, top }: ScrollToOptions) => { + const extent = scroller.scrollWidth - scroller.clientWidth + const min = rtl ? -extent : 0 + const max = rtl ? 0 : extent + scroller.scrollLeft = Math.max(min, Math.min(max, scroller.scrollLeft + (left || 0))) + scroller.scrollTop += top || 0 + }, + }) + return { scroller, child } + } + + it('scrolls a referenced horizontal region without changing its vertical position', () => { + const { scroller, child } = makeHorizontalScroller(100) + register(child) + + expect(runSerialized(scrollPage, ['right', 125, 0])).toMatchObject({ + target: 'Message history', + targetSource: 'element', + scrollLeft: 225, + scrollWidth: 1_000, + clientWidth: 200, + movedBy: 125, + atLeft: false, + atRight: false, + scrollTop: 300, + atTop: false, + atBottom: false, + }) + expect(scroller.scrollTop).toBe(300) + }) + + it('uses viewport width for the default horizontal distance', () => { + const { scroller, child } = makeHorizontalScroller() + Object.defineProperty(scroller, 'scrollWidth', { configurable: true, value: 10_000 }) + register(child) + + expect(scrollPage('right', undefined, 0)).toMatchObject({ + movedBy: Math.round(window.innerWidth * 0.85), + }) + }) + + it('skips a vertical-only descendant when targeting a horizontal ancestor', () => { + const { scroller, child } = makeHorizontalScroller(200) + child.style.overflowY = 'auto' + Object.defineProperties(child, { + clientHeight: { configurable: true, value: 50 }, + scrollHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 100 }, + }) + register(child) + + expect(scrollPage('left', 75, 0)).toMatchObject({ + target: 'Message history', + targetSource: 'element', + movedBy: -75, + scrollLeft: 125, + }) + expect(scroller.scrollTop).toBe(300) + expect(child.scrollTop).toBe(100) + }) + + it('keeps a centered horizontal pane at its boundary instead of scrolling another pane', () => { + const { scroller, child } = makeHorizontalScroller(800) + const other = visible(document.createElement('div')) + other.style.overflowX = 'auto' + other.setAttribute('aria-label', 'Unrelated pane') + Object.defineProperties(other, { + clientWidth: { configurable: true, value: 200 }, + scrollWidth: { configurable: true, value: 1_000 }, + }) + document.body.prepend(other) + Object.defineProperty(document, 'elementsFromPoint', { + configurable: true, + value: () => [child, scroller], + }) + + expect(scrollPage('right', 100)).toMatchObject({ + target: 'Message history', + targetSource: 'viewport-center-boundary', + movedBy: 0, + atRight: true, + }) + expect(other.scrollLeft).toBe(0) + }) + + it.each([ + { direction: 'left', before: 0, after: -100, movedBy: -100, atLeft: false, atRight: false }, + { direction: 'left', before: -750, after: -800, movedBy: -50, atLeft: true, atRight: false }, + { direction: 'left', before: -800, after: -800, movedBy: 0, atLeft: true, atRight: false }, + { direction: 'right', before: -50, after: 0, movedBy: 50, atLeft: false, atRight: true }, + { direction: 'right', before: 0, after: 0, movedBy: 0, atLeft: false, atRight: true }, + ])('scrolls RTL $direction from $before with physical boundaries', (test) => { + const { child } = makeHorizontalScroller(test.before, true) + register(child) + + expect(scrollPage(test.direction, 100, 0)).toMatchObject({ + scrollLeft: test.after, + movedBy: test.movedBy, + atLeft: test.atLeft, + atRight: test.atRight, + }) + }) + + it('scrolls the document root containing an explicit same-origin iframe ref', () => { + document.body.innerHTML = '' + const frame = visible(document.querySelector('iframe') as HTMLIFrameElement) + const frameDocument = frame.contentDocument as Document + const frameWindow = frame.contentWindow as Window + frameDocument.body.innerHTML = '
wide table
' + const child = visible(frameDocument.body.firstElementChild as HTMLDivElement) + const root = visible(frameDocument.documentElement) + Object.defineProperties(root, { + clientWidth: { configurable: true, value: 200 }, + scrollWidth: { configurable: true, value: 1_000 }, + scrollLeft: { configurable: true, writable: true, value: 0 }, + }) + Object.defineProperty(frameWindow, 'scrollX', { configurable: true, writable: true, value: 0 }) + Object.defineProperty(frameWindow, 'scrollBy', { + configurable: true, + value: ({ left }: ScrollToOptions) => { + root.scrollLeft = Math.max(0, Math.min(800, root.scrollLeft + (left || 0))) + Object.defineProperty(frameWindow, 'scrollX', { + configurable: true, + value: root.scrollLeft, + }) + }, + }) + register(child) + + expect(scrollPage('right', 100, 0)).toMatchObject({ + target: 'html', + targetSource: 'element', + scrollLeft: 100, + movedBy: 100, + atLeft: false, + atRight: false, + windowScrollX: 0, + }) + }) + it('scrolls the movable internal container under the viewport center', () => { const { scroller, child } = makeScroller(600) Object.defineProperty(document, 'elementsFromPoint', { diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 2bcd8fc3198..419ae827182 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -2208,6 +2208,7 @@ export function readPageActionState( const roots: ParentNode[] = observationRoot ? [observationRoot] : [] const allElements: Element[] = [] const stateNodeCap = 12_000 + const overlayLimit = 10 for (let index = 0; index < roots.length; index++) { for (const element of Array.from(roots[index].querySelectorAll('*'))) { if (allElements.length >= stateNodeCap) break @@ -2296,48 +2297,46 @@ export function readPageActionState( const dialogs = allElements.filter((element) => element.matches('dialog[open], [role="dialog"], [aria-modal="true"]') ) - const visibleDialogLabels = dialogs - .filter((element) => { - const rect = element.getBoundingClientRect() - const view = element.ownerDocument.defaultView - if (!view || rect.width <= 0 || rect.height <= 0) return false - for (let current: Element | null = element; current; ) { - const style = view.getComputedStyle(current) - if ( - style.display === 'none' || - style.visibility === 'hidden' || - Number.parseFloat(style.opacity || '1') <= 0.01 || - current.hasAttribute('hidden') || - current.getAttribute('aria-hidden') === 'true' - ) { - return false - } - if (current.parentElement) current = current.parentElement - else { - const root = current.getRootNode() - current = 'host' in root ? (root.host as Element) : null - } + const visibleDialogs = dialogs.filter((element) => { + const rect = element.getBoundingClientRect() + const view = element.ownerDocument.defaultView + if (!view || rect.width <= 0 || rect.height <= 0) return false + for (let current: Element | null = element; current; ) { + const style = view.getComputedStyle(current) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number.parseFloat(style.opacity || '1') <= 0.01 || + current.hasAttribute('hidden') || + current.getAttribute('aria-hidden') === 'true' + ) { + return false } - return ( - rect.right > 0 && - rect.bottom > 0 && - rect.left < view.innerWidth && - rect.top < view.innerHeight - ) - }) - .slice(0, 10) - .map((element) => - ( - element.getAttribute('aria-label') || - (element as HTMLElement).innerText || - element.textContent || - '' - ) - .replace(/\s+/g, ' ') - .trim() - .slice(0, 120) - .replace(/[\uD800-\uDBFF]$/, '') + if (current.parentElement) current = current.parentElement + else { + const root = current.getRootNode() + current = 'host' in root ? (root.host as Element) : null + } + } + return ( + rect.right > 0 && + rect.bottom > 0 && + rect.left < view.innerWidth && + rect.top < view.innerHeight ) + }) + const visibleDialogLabels = visibleDialogs.slice(0, overlayLimit).map((element) => + ( + element.getAttribute('aria-label') || + (element as HTMLElement).innerText || + element.textContent || + '' + ) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) + .replace(/[\uD800-\uDBFF]$/, '') + ) // Roles an app uses for something that APPEARS over the page. The first three // were the whole list, which missed the most common hover affordance there @@ -2345,7 +2344,7 @@ export function readPageActionState( // with an aria-label). A hover that mounted one produced no popup change, no // target change, and so no observed effect at all — the agent concluded its // hover had failed and escalated to clicking pixels. - const visiblePopupLabels = allElements + const visiblePopups = allElements .filter((element) => element.matches( '[role="tooltip"], [role="menu"], [role="listbox"], [role="toolbar"], [role="menubar"], [role="group"][aria-label], [popover]' @@ -2367,20 +2366,19 @@ export function readPageActionState( rect.top < view.innerHeight ) }) - .slice(0, 10) - .map((element) => - ( - element.getAttribute('aria-label') || - (element as HTMLElement).innerText || - element.textContent || - element.getAttribute('role') || - '' - ) - .replace(/\s+/g, ' ') - .trim() - .slice(0, 120) - .replace(/[\uD800-\uDBFF]$/, '') + const visiblePopupLabels = visiblePopups.slice(0, overlayLimit).map((element) => + ( + element.getAttribute('aria-label') || + (element as HTMLElement).innerText || + element.textContent || + element.getAttribute('role') || + '' ) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) + .replace(/[\uD800-\uDBFF]$/, '') + ) const scrolledRegions = allElements .filter((element) => (element as HTMLElement).scrollTop !== 0) @@ -2405,13 +2403,19 @@ export function readPageActionState( popups: visiblePopupLabels, scroll: [Math.round(observedWindow.scrollY), ...scrolledRegions], ...(targetState ? { targetState } : {}), - observationTruncated: allElements.length >= stateNodeCap, + observationTruncated: + allElements.length >= stateNodeCap || + visibleDialogs.length > overlayLimit || + visiblePopups.length > overlayLimit, } } export function scrollPage(direction: string, amount?: number, elementId?: number): unknown { - const distance = typeof amount === 'number' && amount > 0 ? amount : window.innerHeight * 0.85 - const delta = direction === 'up' ? -distance : distance + const horizontal = direction === 'left' || direction === 'right' + const viewportSize = horizontal ? window.innerWidth : window.innerHeight + const distance = typeof amount === 'number' && amount > 0 ? amount : viewportSize * 0.85 + const towardStart = direction === 'up' || direction === 'left' + const delta = towardStart ? -distance : distance const scrollingElement = (document.scrollingElement || document.documentElement) as HTMLElement const isVisible = (element: Element): boolean => { @@ -2455,18 +2459,30 @@ export function scrollPage(direction: string, amount?: number, elementId?: numbe } const isScrollable = (element: Element): element is HTMLElement => { const html = element as HTMLElement - if (html.scrollHeight <= html.clientHeight + 1) return false + const scrollSize = horizontal ? html.scrollWidth : html.scrollHeight + const clientSize = horizontal ? html.clientWidth : html.clientHeight + if (scrollSize <= clientSize + 1) return false const ownerScroller = element.ownerDocument.scrollingElement || element.ownerDocument.documentElement if (element === ownerScroller) return true const view = element.ownerDocument.defaultView if (!view) return false - const overflow = view.getComputedStyle(element).overflowY + const style = view.getComputedStyle(element) + const overflow = horizontal ? style.overflowX : style.overflowY return overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay' } + const horizontalBounds = (element: HTMLElement): { min: number; max: number } => { + const extent = Math.max(0, element.scrollWidth - element.clientWidth) + const rtl = element.ownerDocument.defaultView?.getComputedStyle(element).direction === 'rtl' + /** Chromium's RTL scrollLeft runs from a negative left edge to zero at the right edge. */ + return rtl ? { min: -extent, max: 0 } : { min: 0, max: extent } + } const canMove = (element: HTMLElement): boolean => { - const max = Math.max(0, element.scrollHeight - element.clientHeight) - return direction === 'up' ? element.scrollTop > 1 : element.scrollTop < max - 1 + const position = horizontal ? element.scrollLeft : element.scrollTop + const { min, max } = horizontal + ? horizontalBounds(element) + : { min: 0, max: Math.max(0, element.scrollHeight - element.clientHeight) } + return towardStart ? position > min + 1 : position < max - 1 } const ancestors = (start: Element | null): HTMLElement[] => { const result: HTMLElement[] = [] @@ -2584,11 +2600,22 @@ export function scrollPage(direction: string, amount?: number, elementId?: numbe const targetWindow = targetDocument.defaultView const targetDocumentScroller = targetDocument.scrollingElement || targetDocument.documentElement const isDocumentScroller = target === targetDocumentScroller - const before = isDocumentScroller ? (targetWindow?.scrollY ?? target.scrollTop) : target.scrollTop + const position = (): number => { + if (horizontal) { + return isDocumentScroller ? (targetWindow?.scrollX ?? target.scrollLeft) : target.scrollLeft + } + return isDocumentScroller ? (targetWindow?.scrollY ?? target.scrollTop) : target.scrollTop + } + const before = position() + const scrollOptions: ScrollToOptions = horizontal + ? { left: delta, behavior: 'instant' } + : { top: delta, behavior: 'instant' } if (isDocumentScroller && targetWindow) { - targetWindow.scrollBy({ top: delta, behavior: 'instant' }) + targetWindow.scrollBy(scrollOptions) } else if (typeof target.scrollBy === 'function') { - target.scrollBy({ top: delta, behavior: 'instant' }) + target.scrollBy(scrollOptions) + } else if (horizontal) { + target.scrollLeft += delta } else { target.scrollTop += delta } @@ -2601,6 +2628,7 @@ export function scrollPage(direction: string, amount?: number, elementId?: numbe const clientHeight = isDocumentScroller ? (targetWindow?.innerHeight ?? target.clientHeight) : target.clientHeight + const after = position() const label = target.getAttribute('aria-label') || target.getAttribute('role') || @@ -2612,10 +2640,20 @@ export function scrollPage(direction: string, amount?: number, elementId?: numbe scrollTop: Math.round(scrollTop), scrollHeight: Math.round(scrollHeight), clientHeight: Math.round(clientHeight), - movedBy: Math.round(scrollTop - before), + movedBy: Math.round(after - before), atTop: scrollTop <= 1, atBottom: scrollTop + clientHeight >= scrollHeight - 2, windowScrollY: Math.round(window.scrollY), + ...(horizontal + ? { + scrollLeft: Math.round(after), + scrollWidth: Math.round(target.scrollWidth), + clientWidth: Math.round(target.clientWidth), + atLeft: after <= horizontalBounds(target).min + 1, + atRight: after >= horizontalBounds(target).max - 2, + windowScrollX: Math.round(window.scrollX), + } + : {}), } } @@ -2659,6 +2697,106 @@ export function selectOptionInElement(id: number, value: string): unknown { } } +/** Reads and verifies an ordinary top-document form control without exposing secret values. */ +export function readFormFieldState( + id: number, + kind: 'text' | 'select' | 'checked', + expected: string | boolean +): unknown { + const resolver = window.__simAgentResolveElement + const resolved = resolver?.(id) + const registered = resolver ? resolved?.element : (window.__simAgentElements || [])[id] + const element = + String(registered?.tagName || '').toUpperCase() === 'LABEL' + ? (registered as HTMLLabelElement).control + : registered + if (!element?.isConnected) return { error: 'stale' } + if (element.ownerDocument !== document) return { error: 'Form batches require top-page fields.' } + const tag = String(element.tagName || '').toUpperCase() + const input = element as HTMLInputElement + const type = tag === 'INPUT' ? String(input.type || 'text').toLowerCase() : '' + const hints = String(element.getAttribute('autocomplete') || '') + .toLowerCase() + .split(/\s+/) + if ( + tag === 'INPUT' && + (type === 'password' || + hints.some((hint) => hint === 'current-password' || hint === 'new-password')) + ) + return { error: 'password' } + if (element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true') { + return { error: 'disabled' } + } + if (input.readOnly || element.getAttribute('aria-readonly') === 'true') { + return { error: 'readonly' } + } + if (kind === 'checked') { + if (tag !== 'INPUT' || !['checkbox', 'radio'].includes(type)) { + return { error: 'Form batches require native checkboxes or radio buttons.' } + } + if (type === 'radio' && expected === false) + return { error: 'Radio buttons cannot be unchecked directly.' } + return { + matchesRequested: !input.indeterminate && input.checked === expected, + checked: input.checked, + } + } + let value: string + let matchesRequested: boolean + if (kind === 'select') { + if (tag !== 'SELECT' || (element as HTMLSelectElement).multiple) { + return { error: 'Form batches require single-selection native dropdowns.' } + } + const select = element as HTMLSelectElement + const wanted = String(expected).trim().toLowerCase() + const option = Array.from(select.options).find( + (candidate) => + candidate.value.trim().toLowerCase() === wanted || + candidate.label.trim().toLowerCase() === wanted + ) + if ( + !option || + option.disabled || + (option.parentElement as HTMLOptGroupElement | null)?.disabled + ) { + return { error: 'The requested dropdown option is absent or disabled.' } + } + value = select.value + matchesRequested = value === option.value + } else { + if ( + tag !== 'TEXTAREA' && + (tag !== 'INPUT' || !['text', 'search', 'email', 'url', 'tel', 'number'].includes(type)) + ) { + return { error: 'Form batches require ordinary text inputs or textareas.' } + } + value = input.value + matchesRequested = value === expected + } + const redacted = + tag === 'INPUT' && + hints.some((hint) => + ['one-time-code', 'cc-number', 'cc-csc', 'cc-exp', 'cc-exp-month', 'cc-exp-year'].includes( + hint + ) + ) + let active = document.activeElement + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement + return { + matchesRequested, + focused: active === element, + valueLength: value.length, + valuePreview: redacted + ? '' + : value + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) + .replace(/[\uD800-\uDBFF]$/, ''), + redacted, + } +} + export function readSelectElementState(id: number): unknown { const resolver = window.__simAgentResolveElement const resolved = resolver?.(id) diff --git a/apps/docs/content/docs/integrations/quickbooks.mdx b/apps/docs/content/docs/integrations/quickbooks.mdx index 942579aab7d..a9ac02c97a1 100644 --- a/apps/docs/content/docs/integrations/quickbooks.mdx +++ b/apps/docs/content/docs/integrations/quickbooks.mdx @@ -85,7 +85,7 @@ List or read one account, class, customer, department, employee, item, or vendor | `readMode` | string | Yes | Whether to list records or read one record by ID | | `recordId` | string | No | QuickBooks record ID, required for by-ID reads | | `startPosition` | number | No | One-based position of the first list record to return | -| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `maxResults` | number | No | Number of list records to request \(1–1000\) | | `activeStatus` | string | No | List records using the QuickBooks default, active, or inactive status | #### Output @@ -364,7 +364,7 @@ Read, merge, and full-update a non-payroll employee profile | --------- | ---- | -------- | ----------- | | `employeeId` | string | Yes | ID of the employee to update | | `syncToken` | string | Yes | Current employee sync token | -| `displayName` | string | No | Replacement employee display name | +| `displayName` | string | No | Replacement employee display name. Read-only when QuickBooks Payroll is enabled, where QuickBooks derives it from the name components | | `givenName` | string | No | Replacement employee given name | | `familyName` | string | No | Replacement employee family name | | `primaryEmail` | string | No | Replacement employee primary email address | @@ -510,7 +510,7 @@ Create a Service or Non-inventory item in QuickBooks Online | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `name` | string | Yes | Unique item name | +| `name` | string | Yes | Unique item name, up to 100 characters, without tabs, new lines, or colons | | `itemType` | string | Yes | Writable item type: service or non_inventory | | `incomeAccountId` | string | No | Sales of Product Income account ID recording proceeds from the sale. Intuit requires it for Service items except in France locales | | `description` | string | No | Sales description | @@ -562,7 +562,7 @@ Create a Service or Non-inventory item in QuickBooks Online ### QuickBooks Update Item -Read, merge, and full-update an item without changing its type +Read, merge, and full-update a Service or Non-inventory item without changing its type #### Input @@ -570,7 +570,7 @@ Read, merge, and full-update an item without changing its type | --------- | ---- | -------- | ----------- | | `itemId` | string | Yes | ID of the item to update | | `syncToken` | string | Yes | Current item sync token | -| `name` | string | No | Replacement item name | +| `name` | string | No | Replacement item name, up to 100 characters, without tabs, new lines, or colons | | `incomeAccountId` | string | No | Replacement income account ID | | `description` | string | No | Replacement sales description | | `unitPrice` | number | No | Replacement sales price per unit | @@ -578,7 +578,7 @@ Read, merge, and full-update an item without changing its type | `purchaseCost` | number | No | Replacement purchase cost per unit | | `expenseAccountId` | string | No | Replacement expense account ID | | `taxable` | boolean | No | Whether the item is taxable | -| `activeStatus` | string | No | Item status change: unchanged, active, or inactive | +| `activeStatus` | string | No | Item status change: unchanged, active, or inactive. Not valid for Category item types | #### Output @@ -631,7 +631,7 @@ List or read one estimate, invoice, sales receipt, payment, credit memo, or refu | `readMode` | string | Yes | Whether to list transactions or read one transaction by ID | | `transactionId` | string | No | QuickBooks transaction ID, required for by-ID reads | | `startPosition` | number | No | One-based position of the first list record to return | -| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `maxResults` | number | No | Number of list records to request \(1–1000\) | | `startDate` | string | No | List transactions on or after this date in YYYY-MM-DD format | | `endDate` | string | No | List transactions on or before this date in YYYY-MM-DD format | | `customerId` | string | No | List transactions for one QuickBooks customer ID | @@ -1008,7 +1008,7 @@ Create a sales receipt for a completed customer sale | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `customerId` | string | Yes | Customer for the sales receipt | +| `customerId` | string | No | Customer for the sales receipt, omitted for an anonymous sale | | `lines` | json | Yes | Bounded item and description lines | | `transactionDate` | string | No | Sales receipt date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional sales receipt number | @@ -1121,6 +1121,60 @@ Sparse-update a sales receipt using its current sync token | ↳ `CreateTime` | string | Entity creation timestamp | | ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +### QuickBooks Void Sales Receipt + +Void a sales receipt after explicit confirmation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | Sales receipt ID to void | +| `syncToken` | string | Yes | Current sales receipt sync token | +| `confirmVoid` | boolean | Yes | Explicit confirmation that the sales receipt should be voided | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `voided` | boolean | Whether QuickBooks voided the transaction | +| `record` | json | Voided native QuickBooks SalesReceipt | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + ### QuickBooks Create Customer Payment Record a customer payment with optional bounded invoice allocations @@ -1418,7 +1472,7 @@ Create a customer refund receipt against a required deposit account | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `customerId` | string | Yes | Customer receiving the refund | +| `customerId` | string | No | Customer receiving the refund, omitted for an anonymous refund | | `lines` | json | Yes | Bounded item and description lines | | `depositAccountId` | string | Yes | QuickBooks bank account funding the refund | | `transactionDate` | string | No | Refund receipt date in YYYY-MM-DD format | @@ -1472,7 +1526,7 @@ Create a customer refund receipt against a required deposit account ### QuickBooks Update Refund Receipt -Read, merge, and full-update a refund receipt using its current sync token +Sparse-update a refund receipt using its current sync token #### Input @@ -1543,7 +1597,7 @@ List or read one purchase order, bill, bill payment, vendor credit, or purchase | `readMode` | string | Yes | Whether to list transactions or read one transaction by ID | | `transactionId` | string | No | QuickBooks transaction ID, required for by-ID reads | | `startPosition` | number | No | One-based position of the first list record to return | -| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `maxResults` | number | No | Number of list records to request \(1–1000\) | | `startDate` | string | No | List transactions on or after this date in YYYY-MM-DD format | | `endDate` | string | No | List transactions on or before this date in YYYY-MM-DD format | | `vendorId` | string | No | List transactions for one supported QuickBooks vendor ID | @@ -1558,7 +1612,8 @@ List or read one purchase order, bill, bill payment, vendor credit, or purchase | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1607,7 +1662,8 @@ List or read one purchase order, bill, bill payment, vendor credit, or purchase | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1672,6 +1728,9 @@ Create a purchase order with bounded expense lines | `transactionDate` | string | No | Purchase-order date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional purchase-order number | | `privateNote` | string | No | Internal purchase-order note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | +| `dueDate` | string | No | Date the payment is due in YYYY-MM-DD format | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -1687,7 +1746,8 @@ Create a purchase order with bounded expense lines | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1745,6 +1805,7 @@ Read, merge, and full-update purchase-order header fields | `vendorId` | string | No | Replacement vendor ID | | `apAccountId` | string | No | Replacement accounts-payable account ID | | `transactionDate` | string | No | Replacement date in YYYY-MM-DD format | +| `dueDate` | string | No | Replacement due date in YYYY-MM-DD format | | `documentNumber` | string | No | Replacement purchase-order number | | `privateNote` | string | No | Replacement internal note | @@ -1761,7 +1822,8 @@ Read, merge, and full-update purchase-order header fields | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1821,6 +1883,8 @@ Create a vendor bill with optional Purchase Order line links without paying it | `dueDate` | string | No | Bill due date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional bill number | | `privateNote` | string | No | Internal bill note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -1846,7 +1910,8 @@ Create a vendor bill with optional Purchase Order line links without paying it | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1921,7 +1986,8 @@ Read, merge, and full-update bill header fields using its current sync token | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1981,6 +2047,9 @@ Record a check or credit-card payment allocated to one or more bills | `billAllocations` | json | No | Optional bounded Bill-only allocations; any unallocated amount becomes vendor credit | | `transactionDate` | string | No | Payment date in YYYY-MM-DD format | | `privateNote` | string | No | Internal payment note | +| `apAccountId` | string | No | Optional accounts-payable account the payment is credited to | +| `documentNumber` | string | No | Optional reference number for the payment | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -1996,7 +2065,8 @@ Record a check or credit-card payment allocated to one or more bills | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2068,7 +2138,80 @@ Read, merge, and full-update a BillPayment without changing allocations | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Void Bill Payment + +Void a bill payment after explicit confirmation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | BillPayment ID to void | +| `syncToken` | string | Yes | Current BillPayment sync token | +| `confirmVoid` | boolean | Yes | Explicit confirmation that the bill payment should be voided | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `voided` | boolean | Whether QuickBooks voided the transaction | +| `record` | json | Voided native QuickBooks BillPayment | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2127,6 +2270,8 @@ Create a vendor credit without applying it to a bill | `transactionDate` | string | No | Credit date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional vendor-credit number | | `privateNote` | string | No | Internal vendor-credit note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -2142,7 +2287,8 @@ Create a vendor credit without applying it to a bill | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2216,7 +2362,8 @@ Read, merge, and full-update vendor-credit header fields | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2276,6 +2423,8 @@ Record a cash, check, or credit-card purchase with bounded expense lines | `transactionDate` | string | No | Purchase date in YYYY-MM-DD format | | `paymentReference` | string | No | Optional transaction reference number, such as a check number, sent as the purchase DocNumber | | `privateNote` | string | No | Internal purchase note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -2291,7 +2440,8 @@ Record a cash, check, or credit-card purchase with bounded expense lines | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2364,7 +2514,8 @@ Read, merge, and full-update purchase header fields without changing lines | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2421,7 +2572,7 @@ List or read one journal entry, deposit, or transfer | `readMode` | string | Yes | Whether to list transactions or read one transaction by ID | | `transactionId` | string | No | QuickBooks transaction ID, required for by-ID reads | | `startPosition` | number | No | One-based position of the first list record to return | -| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `maxResults` | number | No | Number of list records to request \(1–1000\) | | `startDate` | string | No | List transactions on or after this date in YYYY-MM-DD format | | `endDate` | string | No | List transactions on or before this date in YYYY-MM-DD format | @@ -2494,6 +2645,8 @@ Post a balanced journal entry after explicit confirmation | `transactionDate` | string | No | Journal-entry date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional journal-entry number | | `privateNote` | string | No | Internal journal-entry note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded or TaxInclusive | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -2585,6 +2738,8 @@ Create a deposit with bounded account lines | `lines` | json | Yes | One to 100 account-based deposit lines | | `transactionDate` | string | No | Deposit date in YYYY-MM-DD format | | `privateNote` | string | No | Internal deposit note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -2628,7 +2783,7 @@ Sparse-update deposit header fields using the current sync token and destination | --------- | ---- | -------- | ----------- | | `depositId` | string | Yes | Deposit ID to update | | `syncToken` | string | Yes | Current deposit sync token | -| `depositAccountId` | string | Yes | Current QuickBooks account receiving the deposit | +| `depositAccountId` | string | No | Replacement QuickBooks account receiving the deposit | | `transactionDate` | string | No | Replacement date in YYYY-MM-DD format | | `privateNote` | string | No | Replacement internal note | @@ -2674,11 +2829,14 @@ Run a fixed QuickBooks financial report with verified accountant-focused filters | `reportType` | string | Yes | Fixed QuickBooks financial report to run | | `startDate` | string | No | Report start date in YYYY-MM-DD format; Intuit recommends periods of six months or less for performance | | `endDate` | string | No | Report end or as-of date in YYYY-MM-DD format | +| `dateMacro` | string | No | Predefined QuickBooks report date range, such as this_fiscal_year_to_date; cannot be combined with startDate or endDate | | `accountingMethod` | string | No | Use the QuickBooks default, cash basis, or accrual basis | | `summarizeBy` | string | No | Time period or business dimension used to summarize report columns | +| `quickZoomUrl` | boolean | No | Ask QuickBooks to generate quick-zoom drill-down links, returned as the href on report row values | | `customerId` | string | No | Single QuickBooks customer ID filter | | `vendorId` | string | No | Single QuickBooks vendor ID filter | | `accountId` | string | No | Single QuickBooks account ID filter | +| `employeeId` | string | No | Single QuickBooks employee ID filter, supported by Profit and Loss Detail | | `itemId` | string | No | Single QuickBooks item ID filter | | `classId` | string | No | Single QuickBooks class ID filter | | `departmentId` | string | No | Single QuickBooks department ID filter | @@ -2709,6 +2867,7 @@ Run a fixed QuickBooks financial report with verified accountant-focused filters | ↳ `Customer` | string | Applied customer filter | | ↳ `Vendor` | string | Applied vendor filter | | ↳ `Account` | string | Applied account filter | +| ↳ `Employee` | string | Applied employee filter | | ↳ `Item` | string | Applied item filter | | ↳ `Class` | string | Applied class filter | | ↳ `Department` | string | Applied department filter | @@ -2796,6 +2955,7 @@ Send a supported QuickBooks transaction by email. This causes an external email | ↳ `MetaData` | json | Transaction creation and update timestamps | | ↳ `CreateTime` | string | Entity creation timestamp | | ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `POStatus` | string | Purchase order status | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2813,7 +2973,6 @@ Send a supported QuickBooks transaction by email. This causes an external email | ↳ `PayType` | string | Bill-payment type | | ↳ `CheckPayment` | json | Check payment account details | | ↳ `CreditCardPayment` | json | Credit-card payment account details | -| ↳ `POStatus` | string | Purchase order status | | `time` | string | QuickBooks response timestamp | ### QuickBooks Download Transaction PDF diff --git a/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx b/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx new file mode 100644 index 00000000000..906a040fdd5 --- /dev/null +++ b/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + cn: (...values: Array) => values.filter(Boolean).join(' '), + Tooltip: { + Root: ({ children }: { children: ReactNode }) => <>{children}, + Trigger: ({ children }: { children: ReactNode }) => <>{children}, + Content: () => null, + }, +})) + +vi.mock('@sim/emcn/icons', () => ({ + Check: () => null, + X: () => null, +})) + +vi.mock('next/link', () => ({ + default: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})) + +vi.mock('@/app/(landing)/components', () => ({ BackLink: () => null })) +vi.mock('@/app/(landing)/components/cta/cta', () => ({ Cta: () => null })) +vi.mock('@/app/(landing)/components/json-ld', () => ({ JsonLd: () => null })) +vi.mock('@/app/(landing)/components/landing-faq', () => ({ LandingFAQ: () => null })) +vi.mock('@/app/(landing)/comparisons/components/brand-icon-tile', () => ({ + BrandIconTile: () => null, + SimIconTile: () => null, +})) +vi.mock('@/app/(landing)/comparisons/components/comparison-cards', () => ({ + ComparisonCards: () => null, +})) + +import type { Prose } from '@/lib/compare/data' +import { dustProfile } from '@/lib/compare/data' +import ComparisonProviderPage from '@/app/(landing)/comparisons/[provider]/page' +import { COMPARISON_SECTIONS } from '@/app/(landing)/comparisons/comparison-sections' + +const TOTAL_FACT_ROWS = COMPARISON_SECTIONS.reduce( + (total, section) => total + section.rows.length, + 0 +) + +async function renderProvider(provider: string): Promise { + const element = await ComparisonProviderPage({ params: Promise.resolve({ provider }) }) + return renderToStaticMarkup(element) +} + +function countMatches(markup: string, pattern: RegExp): number { + return markup.match(pattern)?.length ?? 0 +} + +/** + * The opening tag of the anchor whose entire body is `text`. Anchored on the + * link text rather than the href because source-citation links elsewhere on the + * page point at some of the same URLs — matching on href alone silently passes + * against the wrong anchor. + */ +function anchorWrapping(markup: string, text: string): string { + const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return markup.match(new RegExp(`]*>${escaped}`))?.[0] ?? '' +} + +/** Mirrors React's text escaping so data-derived copy can be matched in markup. */ +function escapeForMarkup(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** The rendered text of a {@link Prose} run, links flattened to their labels. */ +function proseText(prose: Prose | undefined): string { + if (!prose) throw new Error('expected the fixture profile to supply this prose field') + return escapeForMarkup(prose.map((s) => (typeof s === 'string' ? s : s.text)).join('')) +} + +describe('ComparisonProviderPage', () => { + it('renders one table per section with every fact row, for a profile with optional prose', async () => { + const markup = await renderProvider('dust') + + expect(countMatches(markup, /role="table"/g)).toBe(COMPARISON_SECTIONS.length) + expect(countMatches(markup, /role="rowheader"/g)).toBe(TOTAL_FACT_ROWS) + }) + + it('renders the same section and row inventory for a profile without optional prose', async () => { + const markup = await renderProvider('n8n') + + expect(countMatches(markup, /role="table"/g)).toBe(COMPARISON_SECTIONS.length) + expect(countMatches(markup, /role="rowheader"/g)).toBe(TOTAL_FACT_ROWS) + }) + + it('gives every section heading an id its section aria-labelledby points at', async () => { + const markup = await renderProvider('dust') + + for (const section of COMPARISON_SECTIONS) { + const headingId = `comparison-section-${section.group}-heading` + expect(markup).toContain(`aria-labelledby="${headingId}"`) + expect(markup).toContain(`id="${headingId}"`) + } + }) + + it('labels each section table distinctly so the seven tables are distinguishable', async () => { + const markup = await renderProvider('dust') + + for (const section of COMPARISON_SECTIONS) { + expect(markup).toContain(`aria-label="Sim vs Dust: ${escapeForMarkup(section.title)}"`) + } + }) + + it('renders the lead answer and verdict bodies only when the profile supplies them', async () => { + const withProse = await renderProvider('dust') + const withoutProse = await renderProvider('n8n') + const lead = proseText(dustProfile.leadAnswer) + const verdict = proseText(dustProfile.betterThanAnswer) + + expect(withProse).toContain('Is Sim better than Dust?') + expect(withProse).toContain('id="better-than-heading"') + expect(withProse).toContain(lead) + expect(withProse).toContain(verdict) + + expect(withoutProse).not.toContain('Is Sim better than n8n?') + expect(withoutProse).not.toContain('id="better-than-heading"') + expect(withoutProse).not.toContain(lead) + expect(withoutProse).not.toContain(verdict) + }) + + it('renders every section intro body the profile supplies, and none when it supplies none', async () => { + const withProse = await renderProvider('dust') + const withoutProse = await renderProvider('n8n') + + for (const section of COMPARISON_SECTIONS) { + const intro = proseText(dustProfile.sectionIntros?.[section.group]) + expect(withProse).toContain(intro) + expect(withoutProse).not.toContain(intro) + } + }) + + it('hardens external prose links and keeps internal ones as plain paths', async () => { + const markup = await renderProvider('openai-agentkit') + + const external = anchorWrapping(markup, 'self-hosting') + expect(external).toContain('href="https://docs.sim.ai/platform/self-hosting"') + expect(external).toContain('target="_blank"') + expect(external).toContain('rel="noopener noreferrer"') + + const internal = anchorWrapping(markup, 'Sim combines a per-user subscription') + expect(internal).toContain('href="/pricing"') + expect(internal).not.toContain('target=') + expect(internal).not.toContain('rel=') + }) +}) diff --git a/apps/sim/app/(landing)/comparisons/[provider]/page.tsx b/apps/sim/app/(landing)/comparisons/[provider]/page.tsx index d61c9b288a1..2497f2180bc 100644 --- a/apps/sim/app/(landing)/comparisons/[provider]/page.tsx +++ b/apps/sim/app/(landing)/comparisons/[provider]/page.tsx @@ -8,6 +8,7 @@ import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparisons/c import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparisons/components/brand-icon-tile' import { ComparisonCards } from '@/app/(landing)/comparisons/components/comparison-cards' import { ComparisonTable } from '@/app/(landing)/comparisons/components/comparison-table' +import { ProseText } from '@/app/(landing)/comparisons/components/prose-text' import { ALL_COMPETITORS, buildBottomLine, @@ -173,6 +174,11 @@ export default async function ComparisonProviderPage({ > Sim vs {competitor.name} + {competitor.leadAnswer ? ( +

+ +

+ ) : null}

Sim is the open-source AI workspace where teams build, deploy, and manage AI agents visually, conversationally, or with code. Here is how Sim compares to{' '} @@ -201,6 +207,22 @@ export default async function ComparisonProviderPage({

+ {competitor.betterThanAnswer ? ( + <> +
+

+ Is Sim better than {competitor.name}? +

+

+ +

+
+
+ + ) : null}
-
+

Sim vs {competitor.name}: feature-by-feature comparison

- +

+ The sections below compare Sim and {competitor.name} across platform and deployment, + pricing, security and compliance, AI capabilities, integrations, observability, and + support. +

+ {COMPARISON_SECTIONS.map((section) => { + const sectionIntro = competitor.sectionIntros?.[section.group] + + return ( +
+

+ {section.title} +

+ {sectionIntro ? ( +

+ +

+ ) : null} + +
+ ) + })} +
diff --git a/apps/sim/app/(landing)/comparisons/comparison-sections.ts b/apps/sim/app/(landing)/comparisons/comparison-sections.ts index adebba03394..93f82aa5ca0 100644 --- a/apps/sim/app/(landing)/comparisons/comparison-sections.ts +++ b/apps/sim/app/(landing)/comparisons/comparison-sections.ts @@ -55,7 +55,7 @@ function defineSection(section: { export const COMPARISON_SECTIONS: ComparisonSectionDef[] = [ defineSection({ group: 'platform', - title: 'Platform', + title: 'Platform & deployment', rows: [ { key: 'builderType', label: 'Builder type' }, { key: 'learningCurve', label: 'Learning curve' }, diff --git a/apps/sim/app/(landing)/comparisons/components/comparison-table/comparison-table.tsx b/apps/sim/app/(landing)/comparisons/components/comparison-table/comparison-table.tsx index a5208b43ab9..7f10f5891ed 100644 --- a/apps/sim/app/(landing)/comparisons/components/comparison-table/comparison-table.tsx +++ b/apps/sim/app/(landing)/comparisons/components/comparison-table/comparison-table.tsx @@ -1,13 +1,18 @@ import type { ReactNode } from 'react' import { cn } from '@sim/emcn' import type { CompetitorProfile } from '@/lib/compare/data' -import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparisons/comparison-sections' +import { + type ComparisonSectionDef, + getFactGroup, +} from '@/app/(landing)/comparisons/comparison-sections' import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparisons/components/brand-icon-tile' import { FactValue } from '@/app/(landing)/comparisons/components/fact-value' export interface ComparisonTableProps { sim: CompetitorProfile competitor: CompetitorProfile + /** The one fact group this table renders. The page gives each its own `h2`. */ + section: ComparisonSectionDef } /** @@ -78,12 +83,15 @@ function ColumnHeader({ * text so crawlers and AI answer engines read the full comparison without * any client-side hydration. */ -export function ComparisonTable({ sim, competitor }: ComparisonTableProps) { +export function ComparisonTable({ sim, competitor, section }: ComparisonTableProps) { + const simGroupFacts = getFactGroup(sim, section.group) + const competitorGroupFacts = getFactGroup(competitor, section.group) + return (
@@ -119,86 +127,54 @@ export function ComparisonTable({ sim, competitor }: ComparisonTableProps) { />
- {COMPARISON_SECTIONS.map((section, sectionIdx) => { - const simGroupFacts = getFactGroup(sim, section.group) - const competitorGroupFacts = getFactGroup(competitor, section.group) + {section.rows.map((row, rowIdx) => { + const simFact = simGroupFacts[row.key] + const competitorFact = competitorGroupFacts[row.key] + const isNotLastRow = rowIdx < section.rows.length - 1 return ( -
-
-
0 && 'border-[var(--border-1)] border-t' - )} - > - - {section.title} - -
-
0 && 'border-[var(--border-1)] border-t' - )} - /> +
+
+ + {row.label} + +
+
+ + {sim.name} + + +
+
+ + {competitor.name} + +
- - {section.rows.map((row, rowIdx) => { - const simFact = simGroupFacts[row.key] - const competitorFact = competitorGroupFacts[row.key] - const isNotLastRow = rowIdx < section.rows.length - 1 - - return ( -
-
- - {row.label} - -
-
- - {sim.name} - - -
-
- - {competitor.name} - - -
-
- ) - })}
) })} diff --git a/apps/sim/app/(landing)/comparisons/components/prose-text/index.ts b/apps/sim/app/(landing)/comparisons/components/prose-text/index.ts new file mode 100644 index 00000000000..348151c91fb --- /dev/null +++ b/apps/sim/app/(landing)/comparisons/components/prose-text/index.ts @@ -0,0 +1,2 @@ +export type { ProseTextProps } from './prose-text' +export { ProseText } from './prose-text' diff --git a/apps/sim/app/(landing)/comparisons/components/prose-text/prose-text.tsx b/apps/sim/app/(landing)/comparisons/components/prose-text/prose-text.tsx new file mode 100644 index 00000000000..bf4a16475a5 --- /dev/null +++ b/apps/sim/app/(landing)/comparisons/components/prose-text/prose-text.tsx @@ -0,0 +1,27 @@ +import { Fragment } from 'react' +import type { Prose } from '@/lib/compare/data' +import { ProseLink } from '@/app/(landing)/components/prose-page' + +export interface ProseTextProps { + prose: Prose +} + +/** + * Renders a {@link Prose} run as plain text with inline links. Pure server + * component so answer engines read the full text without hydration. + */ +export function ProseText({ prose }: ProseTextProps) { + return ( + <> + {prose.map((segment, index) => + typeof segment === 'string' ? ( + {segment} + ) : ( + + {segment.text} + + ) + )} + + ) +} diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index 11d8440abea..5f1bd146087 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -14,6 +14,7 @@ import { workflowsApiUtilsMock, workflowsApiUtilsMockFns, } from '@sim/testing' +import { NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' /** @@ -65,10 +66,18 @@ const createMockStream = () => { }) } -const { mockValidateChatAuth, mockSetChatAuthCookie, mockProcessChatFiles } = vi.hoisted(() => ({ +const { + mockValidateChatAuth, + mockSetChatAuthCookie, + mockProcessChatFiles, + mockEnforceIpRateLimit, + mockEnforceResourceRateLimit, +} = vi.hoisted(() => ({ mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), mockSetChatAuthCookie: vi.fn(), mockProcessChatFiles: vi.fn(), + mockEnforceIpRateLimit: vi.fn(), + mockEnforceResourceRateLimit: vi.fn(), })) const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse @@ -117,6 +126,12 @@ vi.mock('@/lib/core/utils/sse', () => ({ vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceIpRateLimitWithIndependentBackstop: mockEnforceIpRateLimit, + enforceResourceRateLimit: mockEnforceResourceRateLimit, +})) + +import { RATE_LIMITS } from '@/lib/core/rate-limiter/types' import { preprocessExecution } from '@/lib/execution/preprocessing' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' @@ -182,6 +197,8 @@ describe('Chat Identifier API Route', () => { }) mockValidateChatAuth.mockResolvedValue({ authorized: true }) + mockEnforceIpRateLimit.mockResolvedValue(null) + mockEnforceResourceRateLimit.mockResolvedValue(null) mockProcessChatFiles.mockResolvedValue([]) mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => { return new Response( @@ -335,6 +352,107 @@ describe('Chat Identifier API Route', () => { expect(mockSetChatAuthCookie).toHaveBeenCalledWith(expect.anything(), passwordDeployment) }) + describe('execution rate limit', () => { + it.each([ + ['per-IP', mockEnforceIpRateLimit], + ['per-deployment', mockEnforceResourceRateLimit], + ])("refuses on the %s bucket before the owner's budget is reserved", async (_, bucket) => { + bucket.mockResolvedValue( + NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }) + ) + const req = createMockNextRequest('POST', { input: 'drain the wallet' }) + + const response = await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) }) + + expect(response.status).toBe(429) + expect(preprocessExecution).not.toHaveBeenCalled() + expect(createStreamingResponse).not.toHaveBeenCalled() + expect(mockProcessChatFiles).not.toHaveBeenCalled() + }) + + it('debits buckets keyed on the deployment, not the workflow', async () => { + const req = createMockNextRequest('POST', { input: 'hello' }) + + await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) }) + + expect(mockEnforceIpRateLimit).toHaveBeenCalledWith( + 'chat-execute', + req, + expect.objectContaining({ refillIntervalMs: 60_000 }), + 'chat-id' + ) + expect(mockEnforceResourceRateLimit).toHaveBeenCalledWith( + 'chat-execute', + 'chat-id', + expect.objectContaining({ refillIntervalMs: 60_000 }) + ) + }) + + it('leaves the deployment bucket untouched when the IP bucket refuses', async () => { + mockEnforceIpRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 })) + const req = createMockNextRequest('POST', { input: 'flood' }) + + await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) }) + + expect(mockEnforceResourceRateLimit).not.toHaveBeenCalled() + }) + + /** + * The invariant the ceiling exists to hold. A chat execution debits the + * workspace `sync` counter the owner's API, webhook and scheduled runs + * share, so a ceiling at or above a plan's own rate never refuses before + * that shared counter is drained — the availability half of the attack. + * Asserted against every plan, including free, and on burst as well as + * sustained rate, since either one reaching the plan bucket first is the + * same hole. + */ + it.each(Object.keys(RATE_LIMITS))( + 'stays under the %s plan sync budget it debits', + async (plan) => { + const req = createMockNextRequest('POST', { input: 'hello' }) + + await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) }) + + const planBucket = RATE_LIMITS[plan as keyof typeof RATE_LIMITS].sync + const [, , config] = mockEnforceResourceRateLimit.mock.calls[0] + expect(config.refillRate).toBeLessThan(planBucket.refillRate) + expect(config.maxTokens).toBeLessThan(planBucket.maxTokens) + } + ) + + /** One host must not be able to take the whole deployment's allowance. */ + it('holds the per-IP bucket under the per-deployment one', async () => { + const req = createMockNextRequest('POST', { input: 'hello' }) + + await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) }) + + const [, , ipConfig] = mockEnforceIpRateLimit.mock.calls[0] + const [, , deploymentConfig] = mockEnforceResourceRateLimit.mock.calls[0] + expect(ipConfig.refillRate).toBeLessThan(deploymentConfig.refillRate) + }) + + it('leaves the gate-configuration fetch unmetered', async () => { + const passwordDeployment = { + ...mockChatResult[0], + authType: 'password', + password: 'encrypted-password', + } + dbChainMockFns.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([passwordDeployment]), + }), + }), + })) + const req = createMockNextRequest('POST', { password: 'test-password' }) + + await POST(req, { params: Promise.resolve({ identifier: 'password-protected-chat' }) }) + + expect(mockEnforceIpRateLimit).not.toHaveBeenCalled() + expect(mockEnforceResourceRateLimit).not.toHaveBeenCalled() + }) + }) + it('should return 400 for requests without input', async () => { const req = createMockNextRequest('POST', {}) const params = Promise.resolve({ identifier: 'test-chat' }) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 4f855cc1794..7cc25ed7d18 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -9,6 +9,12 @@ import { parseRequest } from '@/lib/api/server' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { env } from '@/lib/core/config/env' +import { + enforceIpRateLimitWithIndependentBackstop, + enforceResourceRateLimit, + type TokenBucketConfig, +} from '@/lib/core/rate-limiter' +import { RATE_LIMITS } from '@/lib/core/rate-limiter/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -49,6 +55,56 @@ export const runtime = 'nodejs' const CHAT_MAX_REQUEST_BYTES = Number.parseInt(env.CHAT_MAX_REQUEST_BYTES, 10) || 220 * 1024 * 1024 +/** A sustained per-minute rate, with the 2x burst allowance the plan buckets use. */ +function executionsPerMinute(perMinute: number): TokenBucketConfig { + return { maxTokens: perMinute * 2, refillRate: perMinute, refillIntervalMs: 60_000 } +} + +/** + * What one deployed chat may spend of its owner's workspace allowance. + * + * A chat execution debits the workspace `sync` counter, which is the same + * counter the owner's API, webhook and scheduled runs draw from. So this + * ceiling only does its job while it sits *below* that counter: above it, a + * flood empties the shared budget before this bucket ever refuses, and the + * billing attack becomes an availability attack on unrelated production + * workloads. + * + * Derived from the plan table rather than picked, because no fixed number holds + * that invariant — the rates differ per plan and every one is operator + * overridable through `RATE_LIMIT_*_SYNC`. A fraction of the smallest + * configured rate keeps a public chat under the shared budget on every plan and + * cannot drift if one of those defaults changes. + * + * The floor is deliberately shared by all plans for now. Sizing the slice to + * the *payer's* own plan needs the subscription, which `preprocessExecution` + * resolves a few lines after this runs, not here. + * + * A configured rate of `1` is the one value where this lands equal to the plan + * rather than under it, because no positive integer is below 1. It is inert: + * a workspace allowed one execution per minute has no capacity left to starve, + * and the two buckets then exhaust together rather than one masking the other. + */ +const CHAT_EXECUTION_RATE_PER_MINUTE = Math.max( + 1, + Math.floor(Math.min(...Object.values(RATE_LIMITS).map((plan) => plan.sync.refillRate)) * 0.8) +) + +const CHAT_EXECUTION_LIMIT = executionsPerMinute(CHAT_EXECUTION_RATE_PER_MINUTE) + +/** + * Executions one client IP may drive against a single deployed chat. + * + * Half the per-deployment rate, so a single source can never consume the whole + * allowance and leave the rest of the audience with none. It is above one + * person's chat cadence but not above a busy office behind one NAT — which + * costs little in practice, since traffic that heavy from one address would + * meet the per-deployment ceiling moments later anyway. + */ +const CHAT_EXECUTION_IP_LIMIT = executionsPerMinute( + Math.max(1, Math.floor(CHAT_EXECUTION_RATE_PER_MINUTE / 2)) +) + export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { const { identifier } = await context.params @@ -169,6 +225,23 @@ export const POST = withRouteHandler( return createErrorResponse('No input provided', 400) } + // Both buckets apply regardless of the chat's auth type: an email or SSO + // visitor is still not the payer. + const ipLimited = await enforceIpRateLimitWithIndependentBackstop( + 'chat-execute', + request, + CHAT_EXECUTION_IP_LIMIT, + deployment.id + ) + if (ipLimited) return ipLimited + + const deploymentLimited = await enforceResourceRateLimit( + 'chat-execute', + deployment.id, + CHAT_EXECUTION_LIMIT + ) + if (deploymentLimited) return deploymentLimited + const executionId = generateId() const loggingSession = new LoggingSession( diff --git a/apps/sim/app/api/chat/validate/route.test.ts b/apps/sim/app/api/chat/validate/route.test.ts new file mode 100644 index 00000000000..518423c3656 --- /dev/null +++ b/apps/sim/app/api/chat/validate/route.test.ts @@ -0,0 +1,75 @@ +/** + * Tests for the chat identifier availability endpoint. + * + * @vitest-environment node + */ +import { authMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnforceUserRateLimit } = vi.hoisted(() => ({ + mockEnforceUserRateLimit: vi.fn(), +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceUserRateLimit: mockEnforceUserRateLimit, +})) + +import { GET } from '@/app/api/chat/validate/route' + +function request(identifier: string) { + return new NextRequest(`http://localhost:3000/api/chat/validate?identifier=${identifier}`) +} + +describe('chat identifier validation route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mockEnforceUserRateLimit.mockResolvedValue(null) + }) + + it('refuses an anonymous caller before answering', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await GET(request('assistant')) + + expect(response.status).toBe(401) + expect(mockEnforceUserRateLimit).not.toHaveBeenCalled() + }) + + it('reports a taken identifier to a signed-in caller', async () => { + queueTableRows(schemaMock.chat, [{ id: 'chat-1' }]) + + const response = await GET(request('assistant')) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + available: false, + error: 'This identifier is already in use', + }) + }) + + it('reports a free identifier to a signed-in caller', async () => { + const response = await GET(request('bot')) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ available: true, error: null }) + }) + + it('caps how far one caller can walk a dictionary', async () => { + mockEnforceUserRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 })) + + const response = await GET(request('support')) + + expect(response.status).toBe(429) + expect(mockEnforceUserRateLimit).toHaveBeenCalledWith( + 'chat-identifier-check', + 'user-1', + expect.objectContaining({ maxTokens: 60, refillIntervalMs: 60_000 }) + ) + }) +}) diff --git a/apps/sim/app/api/chat/validate/route.ts b/apps/sim/app/api/chat/validate/route.ts index c982a9131ba..278c565fee2 100644 --- a/apps/sim/app/api/chat/validate/route.ts +++ b/apps/sim/app/api/chat/validate/route.ts @@ -5,16 +5,39 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { identifierValidationQuerySchema } from '@/lib/api/contracts/chats' import { getValidationErrorMessage } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { enforceUserRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatValidateAPI') /** - * GET endpoint to validate chat identifier availability + * Caps how far one caller can walk a dictionary of identifiers. Sized for a + * debounced availability field, which sends one request per pause in typing. + */ +const IDENTIFIER_CHECK_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 60, + refillRate: 60, + refillIntervalMs: 60_000, +} + +/** + * GET endpoint to validate chat identifier availability. + * + * Chat identifiers are globally unique, so availability cannot be scoped to a + * workspace and there is no resource here to authorize. What the endpoint must + * not be is anonymous: `available: false` names a live deployment, and the chat + * behind it executes its owner's workflow on their budget for anyone holding + * the identifier, so an unmetered answer is a deployment inventory. */ export const GET = withRouteHandler(async (request: NextRequest) => { try { + const session = await getSession() + if (!session?.user?.id) { + return createErrorResponse('Unauthorized', 401) + } + const { searchParams } = new URL(request.url) const identifier = searchParams.get('identifier') @@ -34,6 +57,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return createErrorResponse(errorMessage, 400) } + const rateLimited = await enforceUserRateLimit( + 'chat-identifier-check', + session.user.id, + IDENTIFIER_CHECK_RATE_LIMIT + ) + if (rateLimited) return rateLimited + const { identifier: validatedIdentifier } = validation.data const existingChat = await db diff --git a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts index a2be1cb8ae0..79b13a9336c 100644 --- a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts +++ b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts @@ -20,7 +20,7 @@ vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: vi.fn(() => ({ release: mockRelease })), })) vi.mock('@/lib/webhooks/quickbooks-credentials', () => ({ - getQuickBooksWebhookVerifierTokensByAppKey: mockVerifierTokens, + streamQuickBooksWebhookVerifierTokensByAppKey: mockVerifierTokens, })) vi.mock('@/lib/core/utils/with-route-handler', () => ({ withRouteHandler: @@ -68,10 +68,16 @@ function callPost(webhookRequest: NextRequest, appKey = APP_KEY): Promise { + yield* tokens + }) +} + describe('QuickBooks webhook ingress route', () => { beforeEach(() => { vi.clearAllMocks() - mockVerifierTokens.mockResolvedValue(['verifier']) + mockTokens('verifier') requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1') mockEnqueue.mockResolvedValue('job-1') }) @@ -97,19 +103,33 @@ describe('QuickBooks webhook ingress route', () => { }) it('accepts any verifier token configured by a connection for the same Intuit app', async () => { - mockVerifierTokens.mockResolvedValue(['stale-verifier', 'current-verifier']) + mockTokens('stale-verifier', 'current-verifier') expect((await callPost(signedRequest([validEvent], 'current-verifier'))).status).toBe(200) }) it('fails closed for unknown app keys and missing signatures', async () => { expect((await callPost(signedRequest([validEvent]), 'invalid')).status).toBe(404) - mockVerifierTokens.mockResolvedValueOnce([]) - expect((await callPost(signedRequest([validEvent]))).status).toBe(404) + mockTokens() + expect((await callPost(signedRequest([validEvent]))).status).toBe(401) + mockTokens('verifier') expect((await callPost(request(JSON.stringify([validEvent])))).status).toBe(401) expect(mockEnqueue).not.toHaveBeenCalled() }) + it('acknowledges a batch that carries an unmodelled event instead of stalling the app queue', async () => { + const unmodelledEvent = { ...validEvent, id: 'event-2', type: undefined } + const response = await callPost(signedRequest([validEvent, unmodelledEvent])) + + expect(response.status).toBe(200) + expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ events: [validEvent] })) + }) + + it('acknowledges a batch whose events are all unmodelled without enqueueing', async () => { + expect((await callPost(signedRequest([{ id: 'event-1' }]))).status).toBe(200) + expect(mockEnqueue).not.toHaveBeenCalled() + }) + it('rejects malformed signed payloads and batches over the event bound', async () => { expect((await callPost(signedRequest({ invalid: true }))).status).toBe(400) const events = Array.from({ length: 1001 }, (_, index) => ({ diff --git a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts index 3865dcecfce..518bde8b878 100644 --- a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts +++ b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts @@ -2,7 +2,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { - quickBooksWebhookEventsSchema, + QUICKBOOKS_WEBHOOK_MAX_EVENTS, + type QuickBooksWebhookEvent, + quickBooksWebhookEventSchema, quickBooksWebhookParamsSchema, } from '@/lib/api/contracts/webhooks' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' @@ -14,8 +16,8 @@ import { } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' -import { verifyQuickBooksSignatureAgainstVerifierTokens } from '@/lib/webhooks/providers/quickbooks' -import { getQuickBooksWebhookVerifierTokensByAppKey } from '@/lib/webhooks/quickbooks-credentials' +import { verifyQuickBooksSignatureAgainstVerifierTokenStream } from '@/lib/webhooks/providers/quickbooks' +import { streamQuickBooksWebhookVerifierTokensByAppKey } from '@/lib/webhooks/quickbooks-credentials' import { enqueueQuickBooksWebhookIngress, type QuickBooksWebhookIngressPayload, @@ -62,14 +64,10 @@ export const POST = withRouteHandler( throw error } - const verifierTokens = await getQuickBooksWebhookVerifierTokensByAppKey(appKey) - if (verifierTokens.length === 0) { - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } - const authError = verifyQuickBooksSignatureAgainstVerifierTokens( + const authError = await verifyQuickBooksSignatureAgainstVerifierTokenStream( rawBody, request.headers.get('intuit-signature'), - verifierTokens, + streamQuickBooksWebhookVerifierTokensByAppKey(appKey), requestId ) if (authError) return authError @@ -80,17 +78,33 @@ export const POST = withRouteHandler( } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) } - const parsed = quickBooksWebhookEventsSchema.safeParse(json) - if (!parsed.success) { - logger.warn(`[${requestId}] Invalid QuickBooks webhook envelope`, { - issues: parsed.error.issues, - }) + if ( + !Array.isArray(json) || + json.length === 0 || + json.length > QUICKBOOKS_WEBHOOK_MAX_EVENTS + ) { + logger.warn(`[${requestId}] Invalid QuickBooks webhook envelope`) return NextResponse.json({ error: 'Invalid webhook envelope' }, { status: 400 }) } + const events: QuickBooksWebhookEvent[] = [] + let droppedCount = 0 + for (const entry of json) { + const parsedEvent = quickBooksWebhookEventSchema.safeParse(entry) + if (parsedEvent.success) events.push(parsedEvent.data) + else droppedCount += 1 + } + if (droppedCount > 0) { + logger.warn(`[${requestId}] Dropped unmodelled QuickBooks webhook events`, { + droppedCount, + eventCount: json.length, + }) + } + if (events.length === 0) return NextResponse.json({ ok: true }) + const payload: QuickBooksWebhookIngressPayload = { appKey, - events: parsed.data, + events, headers: { 'content-type': request.headers.get('content-type') ?? 'application/json', }, @@ -99,7 +113,7 @@ export const POST = withRouteHandler( } const jobId = await enqueueQuickBooksWebhookIngress(payload) logger.info(`[${requestId}] Accepted QuickBooks webhook delivery`, { - eventCount: parsed.data.length, + eventCount: events.length, jobId, }) return NextResponse.json({ ok: true }) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index 64268ae2995..5ce0e69d3c4 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -1,97 +1,188 @@ /** - * Tests for workflow chat status route auth and access. + * Tests for the workflow chat-deployment status route. + * + * The route is an adapter over `chat_deployments.list`, so the seams mocked here + * are the canonical workflow/deployment reads and the workspace permission + * resolver — not a route-local access helper. * * @vitest-environment node */ import { - dbChainMockFns, - hybridAuthMockFns, + authMockFns, + queueTableRows, resetDbChainMock, - workflowAuthzMockFns, - workflowsUtilsMock, + resetEnvFlagsMock, + resetEnvMock, + schemaMock, + setEnv, + setEnvFlags, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + loadWorkspaceContext: vi.fn(), + getLiveChatDeploymentForWorkflow: vi.fn(), +})) -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspaceContext, +})) +vi.mock('@/lib/chat-deployments/queries', () => ({ + getLiveChatDeploymentForWorkflow: mocks.getLiveChatDeploymentForWorkflow, + getChatDeploymentWithWorkspace: vi.fn(), + getChatDeploymentIdOwningIdentifier: vi.fn(), + updateChatDeploymentRow: vi.fn(), + listWorkspaceChatDeployments: vi.fn(), +})) +import { chatDeploymentOperations } from '@/lib/chat-deployments/application' import { GET } from '@/app/api/workflows/[id]/chat/status/route' -describe('Workflow Chat Status Route', () => { +const WORKFLOW_ID = 'workflow-1' +const WORKSPACE_ID = 'workspace-1' +const CHAT_ID = 'chat-123' + +const params = { params: Promise.resolve({ id: WORKFLOW_ID }) } + +function request() { + return new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/chat/status`) +} + +/** A deployment configured with every field the admin-gated read serves. */ +function chatRow(overrides: Record = {}) { + return { + id: CHAT_ID, + workflowId: WORKFLOW_ID, + userId: 'owner-1', + identifier: 'victim-support', + title: 'Support', + description: 'Ask us anything', + isActive: true, + customizations: { primaryColor: '#000', welcomeMessage: 'Hi' }, + authType: 'email', + password: 'encrypted-secret', + allowedEmails: ['ceo@victim-corp.com', '@victim-corp.com'], + outputConfigs: [{ blockId: 'block-1', path: 'output' }], + includeThinking: true, + includeToolCalls: null, + archivedAt: null, + createdAt: new Date('2026-06-12T10:30:00.000Z'), + updatedAt: new Date('2026-06-12T10:30:00.000Z'), + ...overrides, + } +} + +beforeAll(() => { + setEnvFlags({ isDev: true }) + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) +}) + +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) + +describe('workflow chat deployment status route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'member-1', name: 'Member', email: 'member@example.com' }, + session: { id: 'session-1' }, + }) + mocks.resolvePermission.mockResolvedValue('read') + mocks.loadWorkspaceContext.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.getLiveChatDeploymentForWorkflow.mockResolvedValue(chatRow()) + queueTableRows(schemaMock.workflow, [ + { workflowId: WORKFLOW_ID, workflow: { id: WORKFLOW_ID }, workspaceId: WORKSPACE_ID }, + ]) }) - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false }) + it('returns 401 when there is no session', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) - const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status') - const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + const response = await GET(request(), params) expect(response.status).toBe(401) + expect(mocks.getLiveChatDeploymentForWorkflow).not.toHaveBeenCalled() }) - it('returns 403 when user lacks workspace access', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, - userId: 'user-1', - authType: 'session', - }) - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ - allowed: false, - status: 403, - message: 'Access denied', - workflow: { id: 'wf-1', workspaceId: 'ws-1' }, - workspacePermission: null, + /** + * The regression this route was: it re-implemented the admin-gated detail + * projection inline at workflow `read`, so any workspace viewer could read + * the `allowedEmails` allow-list, `hasPassword`, and the customization blob + * of a chat exposed to the open internet. The exact-shape assertion is the + * guard — the projection must not widen for any role. + */ + it.each(['read', 'admin'])('withholds the gated fields from a %s member', async (role) => { + mocks.resolvePermission.mockResolvedValue(role) + + const response = await GET(request(), params) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + isDeployed: true, + deployment: { id: CHAT_ID, identifier: 'victim-support' }, }) + }) - const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status') - const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + /** + * Concealed as a not-found rather than the route's previous `403`: this is the + * domain's shared concealment policy, so an outsider cannot use the status + * code to learn that the workflow exists. + */ + it('refuses a caller with no permission on the workspace', async () => { + mocks.resolvePermission.mockResolvedValue(null) - expect(response.status).toBe(403) + const response = await GET(request(), params) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ error: 'Chat not found or access denied' }) }) - it('returns deployment details when authorized', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, - userId: 'user-1', - authType: 'session', - }) - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ - allowed: true, - status: 200, - workflow: { id: 'wf-1', workspaceId: 'ws-1' }, - workspacePermission: 'read', + it('reports an inactive deployment as not deployed while still naming it', async () => { + mocks.getLiveChatDeploymentForWorkflow.mockResolvedValue(chatRow({ isActive: false })) + + const body = await (await GET(request(), params)).json() + + expect(body).toEqual({ + isDeployed: false, + deployment: { id: CHAT_ID, identifier: 'victim-support' }, }) - dbChainMockFns.limit.mockResolvedValueOnce([ - { - id: 'chat-1', - identifier: 'assistant', - title: 'Support Bot', - description: 'desc', - customizations: { theme: 'dark' }, - authType: 'public', - allowedEmails: [], - outputConfigs: [{ blockId: 'agent-1', path: 'content' }], - includeThinking: true, - includeToolCalls: null, - password: 'secret', - isActive: true, - }, - ]) + }) - const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status') - const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + it('reports a workflow with no chat as not deployed', async () => { + mocks.getLiveChatDeploymentForWorkflow.mockResolvedValue(null) - expect(response.status).toBe(200) - const data = await response.json() - expect(data.isDeployed).toBe(true) - expect(data.deployment.id).toBe('chat-1') - expect(data.deployment.hasPassword).toBe(true) - expect(data.deployment.outputConfigs).toEqual([{ blockId: 'agent-1', path: 'content' }]) - expect(data.deployment.includeThinking).toBe(true) - // Independent of thinking: a row without a tool policy reads as off. - expect(data.deployment.includeToolCalls).toBe(false) + const body = await (await GET(request(), params)).json() + + expect(body).toEqual({ isDeployed: false, deployment: null }) + }) + + /** + * The projection above is only safe because the fields it omits stay behind + * an admin operation. If `chat_deployments.read` were ever relaxed, this + * route would no longer be the narrower of the two. + */ + it('keeps the detail read admin-gated and discovery capability-gated', () => { + expect(chatDeploymentOperations.read.minimumRole).toBe('admin') + expect(chatDeploymentOperations.list.minimumRole).toBe('read') + expect(chatDeploymentOperations.list.capability).toBe('deploy.chat') }) }) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index ac79bc10fc9..f7631d8cd99 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -1,91 +1,32 @@ -import { db } from '@sim/db' -import { chat } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { and, eq, isNull } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { getChatDeploymentStatusContract } from '@/lib/api/contracts/deployments' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' - -const logger = createLogger('ChatStatusAPI') +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + chatDeploymentOperations, + readWorkflowChatDeploymentStatus, +} from '@/lib/chat-deployments/application' +import { createInternalChatDeploymentErrorPolicy } from '@/app/api/chat/error-policy' /** - * GET endpoint to check if a workflow has an active chat deployment + * GET — whether a workflow publishes a chat, and which one. + * + * This previously reimplemented a deployment read inline behind a bare workflow + * `read` check, serving the `allowedEmails` allow-list, `hasPassword` and the + * customization blob to any workspace viewer. The editor now gets those from + * `/api/chat/manage/{id}`, which gates them at workspace admin. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const parsed = await parseRequest(getChatDeploymentStatusContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const requestId = generateRequestId() - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return createErrorResponse('Unauthorized', 401) - } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: id, - userId: auth.userId, - action: 'read', - }) - if (!authorization.allowed) { - return createErrorResponse( - authorization.message || 'Access denied', - authorization.status || 403 - ) - } - - // Find any active chat deployments for this workflow - const deploymentResults = await db - .select({ - id: chat.id, - identifier: chat.identifier, - title: chat.title, - description: chat.description, - customizations: chat.customizations, - authType: chat.authType, - allowedEmails: chat.allowedEmails, - outputConfigs: chat.outputConfigs, - includeThinking: chat.includeThinking, - includeToolCalls: chat.includeToolCalls, - password: chat.password, - isActive: chat.isActive, - }) - .from(chat) - .where(and(eq(chat.workflowId, id), isNull(chat.archivedAt))) - .limit(1) - - const isDeployed = deploymentResults.length > 0 && deploymentResults[0].isActive - const deploymentInfo = - deploymentResults.length > 0 - ? { - id: deploymentResults[0].id, - identifier: deploymentResults[0].identifier, - title: deploymentResults[0].title, - description: deploymentResults[0].description, - customizations: deploymentResults[0].customizations, - authType: deploymentResults[0].authType, - allowedEmails: deploymentResults[0].allowedEmails, - outputConfigs: deploymentResults[0].outputConfigs, - includeThinking: deploymentResults[0].includeThinking ?? false, - includeToolCalls: deploymentResults[0].includeToolCalls ?? false, - hasPassword: Boolean(deploymentResults[0].password), - } - : null - - return createSuccessResponse({ - isDeployed, - deployment: deploymentInfo, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error checking chat deployment status:`, error) - return createErrorResponse(error.message || 'Failed to check chat deployment status', 500) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getChatDeploymentStatusContract, + auth: internalSessionAuth, + operation: chatDeploymentOperations.list, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI chat status reads retain their existing admission policy.', + }), + errorPolicy: createInternalChatDeploymentErrorPolicy('Failed to check chat deployment status'), + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: readWorkflowChatDeploymentStatus, + present: ({ isDeployed, deployment }) => ({ isDeployed, deployment }), +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index b250591cb1c..b5e2bb9aed2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -80,6 +80,7 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useOperationAccess } from '@/hooks/use-operation-access' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { supportsForcedToolUse } from '@/providers/models' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils' import type { ActiveSearchTarget } from '@/stores/panel/editor/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -561,10 +562,11 @@ export const ToolInput = memo(function ToolInput({ }) }, [mcpTools, mcpServers]) - const modelValue = useSubBlockStore.getState().getValue(blockId, 'model') + const modelValue = useSubBlockStore((state) => state.getValue(blockId, 'model')) const model = typeof modelValue === 'string' ? modelValue : '' const provider = model ? getProviderFromModel(model) : '' const supportsToolControl = provider ? supportsToolUsageControl(provider) : false + const supportsForce = supportsForcedToolUse(model) const { filterBlocks, @@ -1710,12 +1712,16 @@ export const ToolInput = memo(function ToolInput({ { handleUsageControlChange(toolIndex, 'force') setUsageControlPopoverIndex(null) }} > - Force (always use) + Force{' '} + + {supportsForce ? '(always use)' : '(not supported by model)'} + true), mockExecute: vi.fn(), mockExecuteFromBlock: vi.fn(), + mockFindStartBlock: vi.fn(() => ({ blockId: 'start' })), mockFetch: vi.fn(), mockHandleExecutionCancelledConsole: vi.fn(), mockHandleExecutionErrorConsole: vi.fn(), @@ -180,7 +182,7 @@ vi.mock('@/lib/workflows/triggers/triggers', () => ({ EXTERNAL_TRIGGER: 'external-trigger', }, TriggerUtils: { - findStartBlock: () => ({ blockId: 'start' }), + findStartBlock: mockFindStartBlock, getTriggerValidationMessage: () => 'Missing trigger', }, })) @@ -1097,6 +1099,7 @@ describe('useWorkflowExecution attachment uploads', () => { } executionStoreState.getLastExecutionSnapshot.mockReturnValueOnce(sourceSnapshot) workflowStoreState.edges.push({ source: 'start', target: 'function-1' } as never) + workflowStoreState.edges.push({ source: 'function-1', target: 'disabledBranch' } as never) const currentBlocks = { ...workflowBlocks, 'function-1': { @@ -1106,6 +1109,18 @@ describe('useWorkflowExecution attachment uploads', () => { enabled: true, subBlocks: { code: { value: 'return "current editor state"' } }, }, + disabledBranch: { + id: 'disabledBranch', + type: 'slack', + name: 'Disabled Branch', + enabled: false, + subBlocks: {}, + }, + disabledTrigger: { + ...workflowBlocks.start, + id: 'disabledTrigger', + enabled: false, + }, } workflowStoreState.getWorkflowState.mockReturnValueOnce({ blocks: currentBlocks, @@ -1147,10 +1162,23 @@ describe('useWorkflowExecution attachment uploads', () => { ...workflowBlocks.start, subBlocks: { inputFormat: { value: 'current-editor-state' } }, }, + disabledBranch: { + id: 'disabledBranch', + type: 'slack', + name: 'Disabled Branch', + enabled: false, + subBlocks: {}, + }, + disabledTrigger: { + ...workflowBlocks.start, + id: 'disabledTrigger', + enabled: false, + }, } + const currentEdges = [{ source: 'start', target: 'disabledBranch' }] workflowStoreState.getWorkflowState.mockReturnValueOnce({ blocks: currentBlocks, - edges: [], + edges: currentEdges, loops: {}, parallels: {}, }) @@ -1181,12 +1209,16 @@ describe('useWorkflowExecution attachment uploads', () => { isClientSession: true, workflowStateOverride: { blocks: currentBlocks, - edges: [], + edges: currentEdges, loops: {}, parallels: {}, }, }) ) + expect(mockResolveStartCandidates).toHaveBeenCalledWith( + { start: currentBlocks.start }, + { execution: 'manual' } + ) expect(mockExecute.mock.calls[0]?.[0]).not.toHaveProperty('sourceSnapshot') expect(mockExecuteFromBlock).not.toHaveBeenCalled() expect(executionStoreState.setLastExecutionSnapshot).toHaveBeenCalledWith( @@ -1277,3 +1309,102 @@ describe('useWorkflowExecution attachment uploads', () => { unmount() }) }) + +describe('useWorkflowExecution workflow state override', () => { + beforeEach(() => { + resetWorkflowExecutionTestState() + vi.stubGlobal('fetch', mockFetch) + const startCandidate = { + blockId: 'start', + block: workflowBlocks.start, + path: 'legacy-starter', + } + mockResolveStartCandidates.mockReturnValue([startCandidate]) + mockSelectBestTrigger.mockReturnValue([startCandidate]) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.each(['manual', 'chat', 'run-until'] as const)( + 'keeps disabled blocks and edges in %s payloads while excluding disabled triggers', + async (triggerType) => { + const blocks = { + ...workflowBlocks, + condition1: { + id: 'condition1', + type: 'condition', + name: 'Check', + enabled: true, + subBlocks: {}, + }, + disabledBranch: { + id: 'disabledBranch', + type: 'slack', + name: 'Send Empty', + enabled: false, + subBlocks: {}, + }, + disabledTrigger: { + ...workflowBlocks.start, + id: 'disabledTrigger', + enabled: false, + }, + } + const edges = [ + { + id: 'edge-1', + source: 'condition1', + target: 'disabledBranch', + sourceHandle: 'condition-else1', + }, + ] + workflowStoreState.getWorkflowState.mockReturnValueOnce({ + blocks: { ...blocks, layout: { id: 'layout' } }, + edges, + loops: {}, + parallels: {}, + }) + const { result, unmount } = renderWorkflowExecutionHook() + + await act(async () => { + if (triggerType === 'run-until') { + await result().handleRunUntilBlock('condition1', 'workflow-1') + return + } + const runResult = await result().handleRunWorkflow( + triggerType === 'chat' + ? { + input: 'go', + conversationId: 'conversation-1', + } + : undefined + ) + await drainStream(runResult) + }) + + expect(mockExecute).toHaveBeenCalledTimes(1) + const { workflowStateOverride } = mockExecute.mock.calls[0][0] + const sentBlockIds = new Set(Object.keys(workflowStateOverride.blocks)) + + expect(workflowStateOverride.blocks).toEqual(blocks) + expect(workflowStateOverride.edges).toEqual(edges) + expect(sentBlockIds.has('disabledBranch')).toBe(true) + for (const edge of workflowStateOverride.edges) { + expect(sentBlockIds.has(edge.source)).toBe(true) + expect(sentBlockIds.has(edge.target)).toBe(true) + } + const enabledBlocks = { start: blocks.start, condition1: blocks.condition1 } + if (triggerType === 'chat') { + expect(mockFindStartBlock).toHaveBeenCalledWith(enabledBlocks, 'chat') + } else { + expect(mockResolveStartCandidates).toHaveBeenCalledWith(enabledBlocks, { + execution: 'manual', + }) + } + + unmount() + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 4b25eb8ac05..b215e5305f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -1104,10 +1104,10 @@ export function useWorkflowExecution() { const workflowEdges = (executionWorkflowState?.edges ?? latestWorkflowState.edges) as typeof currentWorkflow.edges - // Filter out blocks without type (these are layout-only blocks) and disabled blocks + /** Keep disabled targets available for routing; the DAG excludes them from execution. */ const validBlocks = Object.entries(workflowBlocks).reduce( (acc, [blockId, block]) => { - if (block?.type && block.enabled !== false) { + if (block?.type) { acc[blockId] = block } return acc @@ -1145,24 +1145,29 @@ export function useWorkflowExecution() { } }) - // Filter out blocks without type and disabled blocks const filteredStates = Object.entries(mergedStates).reduce( (acc, [id, block]) => { if (!block || !block.type) { logger.warn(`Skipping block with undefined type: ${id}`, block) return acc } - // Skip disabled blocks to prevent them from being passed to executor - if (block.enabled === false) { - logger.warn(`Skipping disabled block: ${id}`) - return acc - } acc[id] = block return acc }, {} as typeof mergedStates ) + /** Trigger resolution must never select a disabled trigger. */ + const enabledStates = Object.entries(filteredStates).reduce( + (acc, [id, block]) => { + if (block.enabled !== false) { + acc[id] = block + } + return acc + }, + {} as typeof filteredStates + ) + // If this is a chat execution, get the selected outputs let selectedOutputs: string[] | undefined if (isExecutingFromChat && activeWorkflowId) { @@ -1177,7 +1182,7 @@ export function useWorkflowExecution() { if (isExecutingFromChat) { // For chat execution, find the appropriate chat trigger - const startBlock = TriggerUtils.findStartBlock(filteredStates, 'chat') + const startBlock = TriggerUtils.findStartBlock(enabledStates, 'chat') if (!startBlock) { throw new WorkflowValidationError( @@ -1191,7 +1196,7 @@ export function useWorkflowExecution() { startBlockId = startBlock.blockId } else { // Manual execution: detect and group triggers by paths - const candidates = resolveStartCandidates(filteredStates, { + const candidates = resolveStartCandidates(enabledStates, { execution: 'manual', }) @@ -1203,7 +1208,7 @@ export function useWorkflowExecution() { 'Workflow Validation' ) logger.error('No trigger blocks found for manual run', { - allBlockTypes: Object.values(filteredStates).map((b) => b.type), + allBlockTypes: Object.values(enabledStates).map((b) => b.type), }) if (activeWorkflowId) finishOwnedExecution(activeWorkflowId, persistenceExecution) throw error @@ -2034,15 +2039,15 @@ export function useWorkflowExecution() { const sourceExecutionId = isTriggerBlock ? undefined : effectiveSnapshot.sourceExecutionId const mergedStates = mergeSubblockState(latestWorkflowState.blocks, workflowId) - const executableStates = Object.entries(mergedStates).reduce( + const filteredStates = Object.entries(mergedStates).reduce( (states, [id, block]) => { - if (block?.type && block.enabled !== false) states[id] = block + if (block?.type) states[id] = block return states }, {} as typeof mergedStates ) const workflowStateOverride = workflowStateSchema.parse({ - blocks: executableStates, + blocks: filteredStates, edges: workflowEdges, loops: latestWorkflowState.loops, parallels: latestWorkflowState.parallels, @@ -2051,7 +2056,14 @@ export function useWorkflowExecution() { // Extract mock payload for trigger blocks let workflowInput: any if (isTriggerBlock) { - const candidates = resolveStartCandidates(executableStates, { execution: 'manual' }) + const enabledStates = Object.entries(filteredStates).reduce( + (states, [id, block]) => { + if (block.enabled !== false) states[id] = block + return states + }, + {} as typeof filteredStates + ) + const candidates = resolveStartCandidates(enabledStates, { execution: 'manual' }) const candidate = candidates.find((c) => c.blockId === blockId) if (candidate) { @@ -2069,7 +2081,7 @@ export function useWorkflowExecution() { } } else { // Fallback: block is trigger by position but not classified as start candidate - const block = executableStates[blockId] + const block = enabledStates[blockId] if (block) { const blockConfig = getBlock(block.type) const hasTriggers = blockConfig?.triggers?.available?.length diff --git a/apps/sim/background/quickbooks-webhook-ingress.test.ts b/apps/sim/background/quickbooks-webhook-ingress.test.ts index 8fc7cfd7520..f0b1d113e87 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.test.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.test.ts @@ -125,6 +125,27 @@ describe('QuickBooks webhook ingress job', () => { expect(mockEnqueue).toHaveBeenCalledOnce() }) + it('ignores an event whose company identity can never be routed', async () => { + mockFindWebhooks.mockResolvedValue([]) + const unroutablePayload: QuickBooksWebhookIngressPayload = { + ...payload, + events: [{ ...event, intuitaccountid: 'not-a-realm' }, payload.events[1]], + } + + await expect(executeQuickBooksWebhookIngress(unroutablePayload)).resolves.toEqual({ + failed: 0, + ignored: 1, + processed: 0, + targetCount: 0, + }) + expect(mockFindWebhooks).toHaveBeenCalledOnce() + expect(mockFindWebhooks).toHaveBeenCalledWith( + `${payload.appKey}:789`, + 'request-1', + 'quickbooks' + ) + }) + it('continues later events when targets cannot be resolved', async () => { mockFindWebhooks .mockRejectedValueOnce(new Error('database unavailable')) diff --git a/apps/sim/background/quickbooks-webhook-ingress.ts b/apps/sim/background/quickbooks-webhook-ingress.ts index ae697aa7c1d..3ab7ed54623 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { task } from '@trigger.dev/sdk' import { NextRequest } from 'next/server' import type { QuickBooksWebhookEvent } from '@/lib/api/contracts/webhooks' @@ -37,6 +38,19 @@ export async function executeQuickBooksWebhookIngress( let targetCount = 0 for (const [eventIndex, event] of payload.events.entries()) { + let routingKey: string + try { + routingKey = buildQuickBooksWebhookRoutingKey(payload.appKey, event.intuitaccountid) + } catch (error) { + ignored += 1 + logger.warn(`[${payload.requestId}] QuickBooks webhook event is not routable`, { + error: getErrorMessage(error, 'Unknown error'), + eventId: event.id, + eventIndex, + }) + continue + } + const request = new NextRequest( `http://internal/api/webhooks/quickbooks/${encodeURIComponent(payload.appKey)}`, { @@ -47,7 +61,6 @@ export async function executeQuickBooksWebhookIngress( ) try { - const routingKey = buildQuickBooksWebhookRoutingKey(payload.appKey, event.intuitaccountid) const targets = await findWebhooksByRoutingKey(routingKey, payload.requestId, 'quickbooks') targetCount += targets.length diff --git a/apps/sim/blocks/blocks/quickbooks.ts b/apps/sim/blocks/blocks/quickbooks.ts index e5594175141..762b570386a 100644 --- a/apps/sim/blocks/blocks/quickbooks.ts +++ b/apps/sim/blocks/blocks/quickbooks.ts @@ -5,13 +5,10 @@ import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import { getQuickBooksReportTypesSupporting, - QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES, - QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES, - QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES, - QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES, type QuickBooksReportControl, } from '@/tools/quickbooks/report-metadata' import type { QuickBooksReportType, QuickBooksResponse } from '@/tools/quickbooks/types' +import { QUICKBOOKS_MAX_RESULTS } from '@/tools/quickbooks/values' import { getTrigger } from '@/triggers' const MASTER_DATA_OPERATION = 'quickbooks_read_master_data' @@ -60,6 +57,17 @@ const SALES_CREATE_OPERATIONS = [ ...SALES_DOCUMENT_CREATE_OPERATIONS, 'quickbooks_create_customer_payment', ] as const +/** + * Intuit lists `CustomerRef` in `invoicerequest`, `estimaterequest`, `creditmemorequest`, and + * `paymentrequest`, but not in `salesreceiptrequest` or `refundreceiptrequest`, so those two + * creates leave the customer optional. + */ +const SALES_CUSTOMER_REQUIRED_CREATE_OPERATIONS = [ + 'quickbooks_create_estimate', + 'quickbooks_create_invoice', + 'quickbooks_create_credit_memo', + 'quickbooks_create_customer_payment', +] as const const PURCHASING_CREATE_OPERATIONS = [ 'quickbooks_create_purchase_order', 'quickbooks_create_bill', @@ -84,7 +92,10 @@ const SALES_UPDATE_OPERATIONS = [ const SALES_VOID_OPERATIONS = [ 'quickbooks_void_invoice', 'quickbooks_void_customer_payment', + 'quickbooks_void_sales_receipt', ] as const +const PURCHASING_VOID_OPERATIONS = ['quickbooks_void_bill_payment'] as const +const VOID_OPERATIONS = [...SALES_VOID_OPERATIONS, ...PURCHASING_VOID_OPERATIONS] as const const MASTER_DATA_UPDATE_OPERATIONS = [ 'quickbooks_update_customer', 'quickbooks_update_employee', @@ -120,6 +131,7 @@ const UPDATE_OPERATIONS = [ ...SALES_UPDATE_OPERATIONS, ...SALES_VOID_OPERATIONS, ...PURCHASING_UPDATE_OPERATIONS, + ...PURCHASING_VOID_OPERATIONS, ...ACCOUNTING_UPDATE_OPERATIONS, ] as const const MUTATION_OPERATIONS = [ @@ -129,8 +141,34 @@ const MUTATION_OPERATIONS = [ ...VENDOR_OPERATIONS, ...SALES_MUTATION_OPERATIONS, ...PURCHASING_MUTATION_OPERATIONS, + ...PURCHASING_VOID_OPERATIONS, ...ACCOUNTING_MUTATION_OPERATIONS, ] as const +/** + * `CurrencyRef` is conditionally required on every one of these request models once multicurrency + * is enabled for the company, so each create must be able to send it. + */ +const CURRENCY_CODE_OPERATIONS = [ + 'quickbooks_create_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_create_bill_payment', + 'quickbooks_create_vendor_credit', + 'quickbooks_create_purchase', + 'quickbooks_create_journal_entry', + 'quickbooks_create_deposit', +] as const +/** + * `GlobalTaxCalculation` is documented on the same entities except BillPayment, whose + * `billpaymentresponse` model does not carry it. + */ +const GLOBAL_TAX_CALCULATION_OPERATIONS = [ + 'quickbooks_create_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_create_vendor_credit', + 'quickbooks_create_purchase', + 'quickbooks_create_journal_entry', + 'quickbooks_create_deposit', +] as const const PAGINATED_OPERATIONS = [ MASTER_DATA_OPERATION, SALES_READ_OPERATION, @@ -222,7 +260,7 @@ function getQuickBooksTriggerSubBlocks(): SubBlockConfig[] { ) } -const REPORT_TIME_SUMMARY_OPTIONS = [ +const REPORT_SUMMARY_OPTIONS = [ { label: 'QuickBooks Default', id: 'default' }, { label: 'Total', id: 'total' }, { label: 'Day', id: 'day' }, @@ -230,6 +268,56 @@ const REPORT_TIME_SUMMARY_OPTIONS = [ { label: 'Month', id: 'month' }, { label: 'Quarter', id: 'quarter' }, { label: 'Year', id: 'year' }, + { label: 'Customer', id: 'customer' }, + { label: 'Vendor', id: 'vendor' }, + { label: 'Employee', id: 'employee' }, + { label: 'Product/Service', id: 'item' }, + { label: 'Class', id: 'class' }, + { label: 'Department', id: 'department' }, +] as const + +const REPORT_DATE_MACRO_OPTIONS = [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Today', id: 'today' }, + { label: 'Yesterday', id: 'yesterday' }, + { label: 'This Week', id: 'this_week' }, + { label: 'Last Week', id: 'last_week' }, + { label: 'This Week-to-date', id: 'this_week_to_date' }, + { label: 'Last Week-to-date', id: 'last_week_to_date' }, + { label: 'Next Week', id: 'next_week' }, + { label: 'Next 4 Weeks', id: 'next_4_weeks' }, + { label: 'This Month', id: 'this_month' }, + { label: 'Last Month', id: 'last_month' }, + { label: 'This Month-to-date', id: 'this_month_to_date' }, + { label: 'Last Month-to-date', id: 'last_month_to_date' }, + { label: 'Next Month', id: 'next_month' }, + { label: 'This Fiscal Quarter', id: 'this_fiscal_quarter' }, + { label: 'Last Fiscal Quarter', id: 'last_fiscal_quarter' }, + { label: 'This Fiscal Quarter-to-date', id: 'this_fiscal_quarter_to_date' }, + { label: 'Last Fiscal Quarter-to-date', id: 'last_fiscal_quarter_to_date' }, + { label: 'Next Fiscal Quarter', id: 'next_fiscal_quarter' }, + { label: 'This Fiscal Year', id: 'this_fiscal_year' }, + { label: 'Last Fiscal Year', id: 'last_fiscal_year' }, + { label: 'This Fiscal Year-to-date', id: 'this_fiscal_year_to_date' }, + { label: 'Last Fiscal Year-to-date', id: 'last_fiscal_year_to_date' }, + { label: 'Next Fiscal Year', id: 'next_fiscal_year' }, +] as const + +/** + * Intuit documents `TaxExcluded`, `TaxInclusive`, and `NotApplicable` on every entity carrying + * `GlobalTaxCalculation` except JournalEntry, whose model documents only the first two. + */ +const GLOBAL_TAX_CALCULATION_OPTIONS = [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Tax Excluded', id: 'TaxExcluded' }, + { label: 'Tax Inclusive', id: 'TaxInclusive' }, + { label: 'Not Applicable', id: 'NotApplicable' }, +] as const + +const JOURNAL_ENTRY_GLOBAL_TAX_OPTIONS = [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Tax Excluded', id: 'TaxExcluded' }, + { label: 'Tax Inclusive', id: 'TaxInclusive' }, ] as const function parseJsonInput(value: unknown, fieldName: string): unknown { @@ -289,38 +377,6 @@ function reportSupports(reportType: unknown, control: QuickBooksReportControl): return getQuickBooksReportTypesSupporting(control).includes(reportType as QuickBooksReportType) } -function reportSummarizeValue(params: Record, reportType: unknown): unknown { - if ( - QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES.includes( - reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES)[number] - ) - ) { - return params.reportSummarizeBy ?? 'default' - } - if ( - QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES.includes( - reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES)[number] - ) - ) { - return params.reportCustomerSalesSummarizeBy ?? 'default' - } - if ( - QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES.includes( - reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES)[number] - ) - ) { - return params.reportVendorExpenseSummarizeBy ?? 'default' - } - if ( - QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES.includes( - reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES)[number] - ) - ) { - return params.reportTimeSummarizeBy ?? 'default' - } - return undefined -} - function parseOptionalPositiveInteger(value: unknown, fieldName: string): number | undefined { if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined const parsed = typeof value === 'number' ? value : Number(value) @@ -341,8 +397,8 @@ function parsePaginationInteger( if (fieldName === 'startPosition' && parsed < 1) { throw new Error('startPosition must be a positive integer') } - if (fieldName === 'maxResults' && (parsed < 1 || parsed > 100)) { - throw new Error('maxResults must be an integer from 1 through 100') + if (fieldName === 'maxResults' && (parsed < 1 || parsed > QUICKBOOKS_MAX_RESULTS)) { + throw new Error(`maxResults must be an integer from 1 through ${QUICKBOOKS_MAX_RESULTS}`) } return parsed } @@ -354,6 +410,18 @@ function parseOptionalNumber(value: unknown, fieldName: string): number | undefi return parsed } +/** + * Coerces a switch value to the boolean `applyQuickBooksReportParams` demands. Lives here in + * `tools.config.params`, which runs after variable resolution, so a `` reference + * survives serialization. + */ +function parseOptionalBoolean(value: unknown, fieldName: string): boolean | undefined { + if (value == null || value === '') return undefined + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + throw new Error(`${fieldName} must be true or false`) +} + function parseTriStateBoolean(value: unknown, fieldName: string): boolean | undefined { if (value == null || value === '' || value === 'not_specified') return undefined if (value === true || value === 'yes') return true @@ -361,6 +429,11 @@ function parseTriStateBoolean(value: unknown, fieldName: string): boolean | unde throw new Error(`${fieldName} must be not specified, yes, or no`) } +/** Drops the QuickBooks-default sentinel so the create omits `GlobalTaxCalculation` entirely. */ +function selectedGlobalTaxCalculation(value: unknown): unknown { + return value == null || value === '' || value === 'default' ? undefined : value +} + function optionalValue(value: unknown): unknown { if (value == null) return undefined return typeof value === 'string' && value.trim() === '' ? undefined : value @@ -403,43 +476,56 @@ function paginationCondition(values?: Record) { return { field: 'operation', value: [] } } -function salesTransactionIdCondition(values?: Record) { +/** + * Operations whose `transactionId` names the entity a mutation rewrites or voids. + */ +const TRANSACTION_MUTATION_OPERATIONS = [ + ...SALES_UPDATE_OPERATIONS, + ...SALES_VOID_OPERATIONS, + ...PURCHASING_UPDATE_OPERATIONS, + ...PURCHASING_VOID_OPERATIONS, + ...ACCOUNTING_UPDATE_OPERATIONS, +] as const + +/** + * The by-ID read target, kept apart from the mutation `transactionId`. + * + * Subblock values are keyed by ID and are never cleared when the operation + * changes, so one shared control let a bill ID entered under Read Purchasing + * Transactions survive a switch to Update Purchase Order and silently address + * the wrong entity while the block still validated. + */ +function readTransactionIdCondition(values?: Record) { if (!values) { return { field: 'operation', - value: [ - SALES_READ_OPERATION, - PURCHASING_READ_OPERATION, - ACCOUNTING_READ_OPERATION, - ...SALES_UPDATE_OPERATIONS, - ...SALES_VOID_OPERATIONS, - ...PURCHASING_UPDATE_OPERATIONS, - ...ACCOUNTING_UPDATE_OPERATIONS, - ], + value: [SALES_READ_OPERATION, PURCHASING_READ_OPERATION, ACCOUNTING_READ_OPERATION], } } if ( - values?.operation === SALES_READ_OPERATION || - values?.operation === PURCHASING_READ_OPERATION || - values?.operation === ACCOUNTING_READ_OPERATION + values.operation === SALES_READ_OPERATION || + values.operation === PURCHASING_READ_OPERATION || + values.operation === ACCOUNTING_READ_OPERATION ) { return { field: 'readMode', value: 'by_id' } } - return { - field: 'operation', - value: [ - ...SALES_UPDATE_OPERATIONS, - ...SALES_VOID_OPERATIONS, - ...PURCHASING_UPDATE_OPERATIONS, - ...ACCOUNTING_UPDATE_OPERATIONS, - ], - } + return { field: 'operation', value: [] } } function parseConfirmation(value: unknown, fieldName: string): boolean { - if (value === true || value === 'yes') return true - if (value === false || value === 'no' || value == null || value === '') return false - throw new Error(`${fieldName} must be yes or no`) + switch (value) { + case true: + case 'yes': + return true + case false: + case 'no': + case null: + case undefined: + case '': + return false + default: + throw new Error(`${fieldName} must be yes or no`) + } } function attachmentTargetCondition(values?: Record) { @@ -552,6 +638,9 @@ export const QuickBooksBlock: BlockConfig = { quickbooks_update_sales_receipt: [ { text: 'Update sales receipt', field: 'transactionId', core: true }, ], + quickbooks_void_sales_receipt: [ + { text: 'Void sales receipt', field: 'transactionId', core: true }, + ], quickbooks_create_customer_payment: [ { text: 'Record payment from customer', @@ -620,6 +709,9 @@ export const QuickBooksBlock: BlockConfig = { quickbooks_update_bill_payment: [ { text: 'Update bill payment', field: 'transactionId', core: true }, ], + quickbooks_void_bill_payment: [ + { text: 'Void bill payment', field: 'transactionId', core: true }, + ], quickbooks_create_vendor_credit: [ { text: 'Create a credit for vendor', field: 'vendorId', core: true }, ], @@ -718,6 +810,10 @@ export const QuickBooksBlock: BlockConfig = { label: 'Update Sales Receipt', id: 'quickbooks_update_sales_receipt', }, + { + label: 'Void Sales Receipt', + id: 'quickbooks_void_sales_receipt', + }, { label: 'Create Customer Payment', id: 'quickbooks_create_customer_payment', @@ -756,6 +852,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Update Bill', id: 'quickbooks_update_bill' }, { label: 'Create Bill Payment', id: 'quickbooks_create_bill_payment' }, { label: 'Update Bill Payment', id: 'quickbooks_update_bill_payment' }, + { label: 'Void Bill Payment', id: 'quickbooks_void_bill_payment' }, { label: 'Create Vendor Credit', id: 'quickbooks_create_vendor_credit', @@ -1004,10 +1101,22 @@ export const QuickBooksBlock: BlockConfig = { id: 'attachmentFileName', title: 'File Name', type: 'short-input', - placeholder: 'Optional safe filename override', + placeholder: 'Optional uploaded filename override', condition: { field: 'operation', - value: [ADD_ATTACHMENT_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'file' }, + }, + mode: 'advanced', + }, + { + id: 'downloadAttachmentFileName', + title: 'File Name', + type: 'short-input', + placeholder: 'Optional saved filename override', + condition: { + field: 'operation', + value: DOWNLOAD_ATTACHMENT_OPERATION, }, mode: 'advanced', }, @@ -1218,33 +1327,52 @@ export const QuickBooksBlock: BlockConfig = { required: { field: 'operation', value: ACCOUNTING_READ_OPERATION }, value: () => 'journal_entry', }, + { + id: 'readTransactionId', + title: 'Transaction ID', + type: 'short-input', + placeholder: 'QuickBooks transaction ID', + condition: readTransactionIdCondition, + required: readTransactionIdCondition, + }, { id: 'transactionId', title: 'Transaction ID', type: 'short-input', placeholder: 'QuickBooks transaction ID', - condition: salesTransactionIdCondition, - required: salesTransactionIdCondition, + condition: { field: 'operation', value: [...TRANSACTION_MUTATION_OPERATIONS] }, + required: { field: 'operation', value: [...TRANSACTION_MUTATION_OPERATIONS] }, }, { id: 'reportType', title: 'Report Type', type: 'dropdown', options: [ + { label: 'Account List Detail', id: 'account_list_detail' }, { label: 'Balance Sheet', id: 'balance_sheet' }, { label: 'Profit and Loss', id: 'profit_and_loss' }, { label: 'Profit and Loss Detail', id: 'profit_and_loss_detail' }, { label: 'Trial Balance', id: 'trial_balance' }, + { label: 'Trial Balance (France locale)', id: 'trial_balance_fr' }, { label: 'Statement of Cash Flows', id: 'cash_flow' }, + { label: 'General Ledger Detail', id: 'general_ledger_detail' }, { label: 'A/P Aging Summary', id: 'ap_aging_summary' }, { label: 'A/P Aging Detail', id: 'ap_aging_detail' }, { label: 'A/R Aging Summary', id: 'ar_aging_summary' }, { label: 'A/R Aging Detail', id: 'ar_aging_detail' }, { label: 'Vendor Balance Summary', id: 'vendor_balance' }, + { label: 'Vendor Balance Detail', id: 'vendor_balance_detail' }, { label: 'Customer Balance Summary', id: 'customer_balance' }, + { label: 'Customer Balance Detail', id: 'customer_balance_detail' }, + { label: 'Income by Customer Summary', id: 'customer_income' }, { label: 'Sales by Customer Summary', id: 'sales_by_customer' }, { label: 'Sales by Product/Service Summary', id: 'sales_by_item' }, + { label: 'Sales by Class Summary', id: 'sales_by_class' }, + { label: 'Sales by Department Summary', id: 'sales_by_department' }, { label: 'Expenses by Vendor', id: 'expenses_by_vendor' }, + { label: 'Inventory Valuation Summary', id: 'inventory_valuation_summary' }, + { label: 'Inventory Valuation Detail', id: 'inventory_valuation_detail' }, + { label: 'Tax Summary (non-US locale)', id: 'tax_summary' }, { label: 'Transaction List', id: 'transaction_list' }, ], condition: { field: 'operation', value: REPORT_OPERATION }, @@ -1286,87 +1414,32 @@ export const QuickBooksBlock: BlockConfig = { value: () => 'default', }, { - id: 'reportSummarizeBy', - title: 'Summarize Columns By', + id: 'reportDateMacro', + title: 'Date Range', type: 'dropdown', - options: [ - ...REPORT_TIME_SUMMARY_OPTIONS, - { label: 'Customer', id: 'customer' }, - { label: 'Vendor', id: 'vendor' }, - { label: 'Product/Service', id: 'item' }, - { label: 'Class', id: 'class' }, - { label: 'Department', id: 'department' }, - ], - mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { - field: 'reportType', - value: [...QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES], - }, - }, - value: () => 'default', - }, - { - id: 'reportCustomerSalesSummarizeBy', - title: 'Summarize Columns By', - type: 'dropdown', - options: [ - ...REPORT_TIME_SUMMARY_OPTIONS, - { label: 'Customer', id: 'customer' }, - { label: 'Product/Service', id: 'item' }, - { label: 'Class', id: 'class' }, - { label: 'Department', id: 'department' }, - ], + options: [...REPORT_DATE_MACRO_OPTIONS], + description: + 'Predefined report range. Cannot be combined with an explicit start or end date.', mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { - field: 'reportType', - value: [...QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES], - }, - }, + condition: reportControlCondition('dateMacro'), value: () => 'default', }, { - id: 'reportVendorExpenseSummarizeBy', + id: 'reportSummarizeBy', title: 'Summarize Columns By', type: 'dropdown', - options: [ - ...REPORT_TIME_SUMMARY_OPTIONS, - { label: 'Customer', id: 'customer' }, - { label: 'Vendor', id: 'vendor' }, - { label: 'Class', id: 'class' }, - { label: 'Department', id: 'department' }, - ], + options: [...REPORT_SUMMARY_OPTIONS], mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { - field: 'reportType', - value: [...QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES], - }, - }, + condition: reportControlCondition('summarizeBy'), value: () => 'default', }, { - id: 'reportTimeSummarizeBy', - title: 'Summarize Columns By', - type: 'dropdown', - options: [...REPORT_TIME_SUMMARY_OPTIONS], + id: 'reportQuickZoomUrl', + title: 'Include Quick Zoom Links', + type: 'switch', + description: 'Adds the QuickBooks drill-down href to each report row that supports one.', mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { - field: 'reportType', - value: [...QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES], - }, - }, - value: () => 'default', + condition: reportControlCondition('quickZoomUrl'), }, { id: 'reportCustomerId', @@ -1400,6 +1473,14 @@ export const QuickBooksBlock: BlockConfig = { mode: 'advanced', condition: reportControlCondition('itemId'), }, + { + id: 'reportEmployeeId', + title: 'Employee ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find an employee ID', + mode: 'advanced', + condition: reportControlCondition('employeeId'), + }, { id: 'reportClassId', title: 'Class ID', @@ -1491,11 +1572,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Year', id: 'year' }, ], mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { field: 'reportType', value: 'transaction_list' }, - }, + condition: reportControlCondition('groupBy'), value: () => 'default', }, { @@ -1509,11 +1586,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Unpaid', id: 'unpaid' }, ], mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { field: 'reportType', value: 'transaction_list' }, - }, + condition: reportControlCondition('accountsPayablePaid'), value: () => 'default', }, { @@ -1527,11 +1600,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Unpaid', id: 'unpaid' }, ], mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { field: 'reportType', value: 'transaction_list' }, - }, + condition: reportControlCondition('accountsReceivablePaid'), value: () => 'default', }, { @@ -1625,7 +1694,7 @@ export const QuickBooksBlock: BlockConfig = { }, required: { field: 'operation', - value: ['quickbooks_update_customer', ...SALES_CREATE_OPERATIONS], + value: ['quickbooks_update_customer', ...SALES_CUSTOMER_REQUIRED_CREATE_OPERATIONS], }, }, { @@ -1877,7 +1946,9 @@ export const QuickBooksBlock: BlockConfig = { id: 'incomeAccountId', title: 'Income Account ID', type: 'short-input', - placeholder: 'QuickBooks income account ID', + placeholder: 'Required for Service items outside France locales', + description: + 'QuickBooks requires an income account for Service items, except for companies on a France locale.', condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, }, { @@ -1900,7 +1971,6 @@ export const QuickBooksBlock: BlockConfig = { type: 'long-input', placeholder: 'Item purchase description', condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, - mode: 'advanced', }, { id: 'purchaseCost', @@ -1908,13 +1978,14 @@ export const QuickBooksBlock: BlockConfig = { type: 'short-input', placeholder: '0.00', condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, - mode: 'advanced', }, { id: 'expenseAccountId', title: 'Expense Account ID', type: 'short-input', - placeholder: 'QuickBooks expense account ID', + placeholder: 'Required for Service and Non-inventory items outside France locales', + description: + 'QuickBooks requires an expense account for Service and Non-inventory items, except for companies on a France locale.', condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, }, { @@ -1942,6 +2013,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Active', id: 'active' }, { label: 'Inactive', id: 'inactive' }, ], + mode: 'advanced', condition: { field: 'operation', value: [...MASTER_DATA_UPDATE_OPERATIONS], @@ -2060,6 +2132,7 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_update_purchase_order', 'quickbooks_create_bill', 'quickbooks_update_bill', + 'quickbooks_create_bill_payment', 'quickbooks_create_vendor_credit', 'quickbooks_update_vendor_credit', ], @@ -2157,6 +2230,8 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_update_invoice', 'quickbooks_create_bill', 'quickbooks_update_bill', + 'quickbooks_create_purchase_order', + 'quickbooks_update_purchase_order', ], }, mode: 'advanced', @@ -2187,6 +2262,7 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_update_purchase_order', 'quickbooks_create_bill', 'quickbooks_update_bill', + 'quickbooks_create_bill_payment', 'quickbooks_create_vendor_credit', 'quickbooks_update_vendor_credit', 'quickbooks_create_journal_entry', @@ -2195,6 +2271,30 @@ export const QuickBooksBlock: BlockConfig = { }, mode: 'advanced', }, + { + id: 'currencyCode', + title: 'Currency Code', + type: 'short-input', + placeholder: 'USD', + description: + 'Three-letter ISO 4217 code. QuickBooks requires it once multicurrency is enabled for the company.', + condition: { field: 'operation', value: [...CURRENCY_CODE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'globalTaxCalculation', + title: 'Tax Treatment', + type: 'dropdown', + options: ({ values } = { values: {} }) => + values?.operation === 'quickbooks_create_journal_entry' + ? [...JOURNAL_ENTRY_GLOBAL_TAX_OPTIONS] + : [...GLOBAL_TAX_CALCULATION_OPTIONS], + description: + 'How QuickBooks applies tax. Not applicable to US companies; required for non-US companies.', + condition: { field: 'operation', value: [...GLOBAL_TAX_CALCULATION_OPERATIONS] }, + mode: 'advanced', + value: () => 'default', + }, { id: 'privateNote', title: 'Private Note', @@ -2286,7 +2386,6 @@ export const QuickBooksBlock: BlockConfig = { language: 'json', placeholder: '[{"invoiceId":"42","amount":75}]', condition: { field: 'operation', value: [...PAYMENT_OPERATIONS] }, - mode: 'advanced', wandConfig: { enabled: true, placeholder: 'Describe how the payment should be allocated across invoices', @@ -2338,8 +2437,8 @@ export const QuickBooksBlock: BlockConfig = { { label: 'No', id: 'no' }, { label: 'Yes', id: 'yes' }, ], - condition: { field: 'operation', value: [...SALES_VOID_OPERATIONS] }, - required: { field: 'operation', value: [...SALES_VOID_OPERATIONS] }, + condition: { field: 'operation', value: [...VOID_OPERATIONS] }, + required: { field: 'operation', value: [...VOID_OPERATIONS] }, value: () => 'no', }, { @@ -2386,6 +2485,7 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_void_invoice', 'quickbooks_create_sales_receipt', 'quickbooks_update_sales_receipt', + 'quickbooks_void_sales_receipt', 'quickbooks_create_customer_payment', 'quickbooks_update_customer_payment', 'quickbooks_void_customer_payment', @@ -2400,6 +2500,7 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_update_bill', 'quickbooks_create_bill_payment', 'quickbooks_update_bill_payment', + 'quickbooks_void_bill_payment', 'quickbooks_create_vendor_credit', 'quickbooks_update_vendor_credit', 'quickbooks_create_purchase', @@ -2486,7 +2587,7 @@ export const QuickBooksBlock: BlockConfig = { return { credential: oauthCredentialValue, attachmentId: optionalValue(params.attachmentId), - fileName: optionalValue(params.attachmentFileName), + fileName: optionalValue(params.downloadAttachmentFileName), } } @@ -2514,7 +2615,7 @@ export const QuickBooksBlock: BlockConfig = { credential: oauthCredentialValue, transactionType: params.transactionType, readMode: params.readMode, - transactionId: optionalValue(params.transactionId), + transactionId: optionalValue(params.readTransactionId), } } return { @@ -2534,7 +2635,7 @@ export const QuickBooksBlock: BlockConfig = { credential: oauthCredentialValue, transactionType: params.purchasingTransactionType, readMode: params.readMode, - transactionId: optionalValue(params.transactionId), + transactionId: optionalValue(params.readTransactionId), } } return { @@ -2557,7 +2658,7 @@ export const QuickBooksBlock: BlockConfig = { credential: oauthCredentialValue, transactionType: params.accountingTransactionType, readMode: params.readMode, - transactionId: optionalValue(params.transactionId), + transactionId: optionalValue(params.readTransactionId), } } return { @@ -2579,11 +2680,17 @@ export const QuickBooksBlock: BlockConfig = { ? optionalValue(params.reportStartDate) : undefined, endDate: optionalValue(params.reportEndDate), + dateMacro: reportSupports(reportType, 'dateMacro') + ? (params.reportDateMacro ?? 'default') + : undefined, accountingMethod: reportSupports(reportType, 'accountingMethod') ? (params.reportAccountingMethod ?? 'default') : undefined, summarizeBy: reportSupports(reportType, 'summarizeBy') - ? reportSummarizeValue(params, reportType) + ? (params.reportSummarizeBy ?? 'default') + : undefined, + quickZoomUrl: reportSupports(reportType, 'quickZoomUrl') + ? parseOptionalBoolean(params.reportQuickZoomUrl, 'quickZoomUrl') : undefined, customerId: reportSupports(reportType, 'customerId') ? optionalValue(params.reportCustomerId) @@ -2594,6 +2701,9 @@ export const QuickBooksBlock: BlockConfig = { accountId: reportSupports(reportType, 'accountId') ? optionalValue(params.reportAccountId) : undefined, + employeeId: reportSupports(reportType, 'employeeId') + ? optionalValue(params.reportEmployeeId) + : undefined, itemId: reportSupports(reportType, 'itemId') ? optionalValue(params.reportItemId) : undefined, @@ -2614,15 +2724,17 @@ export const QuickBooksBlock: BlockConfig = { ? params.reportTransactionType : undefined, groupBy: - reportType === 'transaction_list' && params.reportGroupBy !== 'default' + reportSupports(reportType, 'groupBy') && params.reportGroupBy !== 'default' ? params.reportGroupBy : undefined, accountsPayablePaid: - reportType === 'transaction_list' && params.reportAccountsPayablePaid !== 'default' + reportSupports(reportType, 'accountsPayablePaid') && + params.reportAccountsPayablePaid !== 'default' ? params.reportAccountsPayablePaid : undefined, accountsReceivablePaid: - reportType === 'transaction_list' && params.reportAccountsReceivablePaid !== 'default' + reportSupports(reportType, 'accountsReceivablePaid') && + params.reportAccountsReceivablePaid !== 'default' ? params.reportAccountsReceivablePaid : undefined, clearedStatus: @@ -2639,7 +2751,7 @@ export const QuickBooksBlock: BlockConfig = { : undefined, } } - if (SALES_VOID_OPERATIONS.includes(operation as (typeof SALES_VOID_OPERATIONS)[number])) { + if (VOID_OPERATIONS.includes(operation as (typeof VOID_OPERATIONS)[number])) { return { credential: oauthCredentialValue, transactionId: optionalValue(params.transactionId), @@ -2741,7 +2853,7 @@ export const QuickBooksBlock: BlockConfig = { syncToken: isCreate ? undefined : optionalValue(params.syncToken), vendorId: optionalValue(params.vendorId), apAccountId: - isPurchaseOrder || isBill || isVendorCredit + isPurchaseOrder || isBill || isVendorCredit || (isCreate && isBillPayment) ? optionalValue(params.apAccountId) : undefined, lines: @@ -2767,11 +2879,16 @@ export const QuickBooksBlock: BlockConfig = { ? parseJsonArrayInput(params.billAllocations, 'billAllocations') : undefined, transactionDate: optionalValue(params.transactionDate), - dueDate: isBill ? optionalValue(params.dueDate) : undefined, + dueDate: isBill || isPurchaseOrder ? optionalValue(params.dueDate) : undefined, documentNumber: - isPurchaseOrder || isBill || isVendorCredit + isPurchaseOrder || isBill || isVendorCredit || (isCreate && isBillPayment) ? optionalValue(params.documentNumber) : undefined, + currencyCode: isCreate ? optionalValue(params.currencyCode) : undefined, + globalTaxCalculation: + isCreate && !isBillPayment + ? selectedGlobalTaxCalculation(params.globalTaxCalculation) + : undefined, paymentReference: isPurchase ? optionalValue(params.paymentReference) : undefined, privateNote: optionalValue(params.privateNote), requestId: isCreate ? optionalValue(params.requestId) : undefined, @@ -2807,6 +2924,10 @@ export const QuickBooksBlock: BlockConfig = { depositAccountId: !isJournalEntry ? optionalValue(params.depositAccountId) : undefined, transactionDate: optionalValue(params.transactionDate), documentNumber: isJournalEntry ? optionalValue(params.documentNumber) : undefined, + currencyCode: isCreate ? optionalValue(params.currencyCode) : undefined, + globalTaxCalculation: isCreate + ? selectedGlobalTaxCalculation(params.globalTaxCalculation) + : undefined, privateNote: optionalValue(params.privateNote), requestId: isCreate ? optionalValue(params.requestId) : undefined, } @@ -2928,21 +3049,17 @@ export const QuickBooksBlock: BlockConfig = { type: 'string', description: 'Cash or accrual report basis', }, - reportSummarizeBy: { + reportDateMacro: { type: 'string', - description: 'Report column summarization', + description: 'Predefined report date range', }, - reportCustomerSalesSummarizeBy: { - type: 'string', - description: 'Sales report column summarization', - }, - reportVendorExpenseSummarizeBy: { + reportSummarizeBy: { type: 'string', - description: 'Vendor expense report column summarization', + description: 'Report column summarization', }, - reportTimeSummarizeBy: { - type: 'string', - description: 'Time-based report column summarization', + reportQuickZoomUrl: { + type: 'boolean', + description: 'Whether to request quick-zoom drill-down links', }, reportCustomerId: { type: 'string', @@ -2953,6 +3070,10 @@ export const QuickBooksBlock: BlockConfig = { type: 'string', description: 'Account report filter ID', }, + reportEmployeeId: { + type: 'string', + description: 'Employee report filter ID', + }, reportItemId: { type: 'string', description: 'Product or service report filter ID', @@ -2974,14 +3095,14 @@ export const QuickBooksBlock: BlockConfig = { type: 'string', description: 'Transaction List type filter', }, - reportGroupBy: { type: 'string', description: 'Transaction List grouping' }, + reportGroupBy: { type: 'string', description: 'Report row grouping' }, reportAccountsPayablePaid: { type: 'string', - description: 'Transaction List A/P status', + description: 'Report payables paid status', }, reportAccountsReceivablePaid: { type: 'string', - description: 'Transaction List A/R status', + description: 'Report receivables paid status', }, reportClearedStatus: { type: 'string', @@ -3012,14 +3133,21 @@ export const QuickBooksBlock: BlockConfig = { type: 'string', description: 'Purchasing list vendor filter', }, - transactionId: { type: 'string', description: 'QuickBooks transaction ID' }, + readTransactionId: { + type: 'string', + description: 'QuickBooks transaction ID to read by ID', + }, + transactionId: { + type: 'string', + description: 'QuickBooks transaction ID to update or void', + }, startPosition: { type: 'number', description: 'One-based position of the first list item to request', }, maxResults: { type: 'number', - description: 'Number of list items to request, from 1 through 100', + description: `Number of list items to request, from 1 through ${QUICKBOOKS_MAX_RESULTS}`, }, customerId: { type: 'string', description: 'QuickBooks customer ID' }, vendorId: { type: 'string', description: 'QuickBooks vendor ID' }, @@ -3135,7 +3263,15 @@ export const QuickBooksBlock: BlockConfig = { }, dueDate: { type: 'string', - description: 'Invoice due date in YYYY-MM-DD format', + description: 'Invoice, bill, or purchase-order due date in YYYY-MM-DD format', + }, + currencyCode: { + type: 'string', + description: 'Three-letter ISO 4217 transaction currency code', + }, + globalTaxCalculation: { + type: 'string', + description: 'Tax treatment applied to the transaction', }, expirationDate: { type: 'string', @@ -3230,7 +3366,11 @@ export const QuickBooksBlock: BlockConfig = { }, attachmentFileName: { type: 'string', - description: 'Optional attachment filename override', + description: 'Optional uploaded attachment filename override', + }, + downloadAttachmentFileName: { + type: 'string', + description: 'Optional downloaded attachment filename override', }, attachmentContentType: { type: 'string', @@ -3373,7 +3513,7 @@ export const QuickBooksBlock: BlockConfig = { voided: { type: 'boolean', description: 'True when QuickBooks successfully voided the transaction', - condition: { field: 'operation', value: [...SALES_VOID_OPERATIONS] }, + condition: { field: 'operation', value: [...VOID_OPERATIONS] }, }, linkingRequested: { type: 'boolean', diff --git a/apps/sim/content/library/what-is-retrieval-augmented-generation/index.mdx b/apps/sim/content/library/what-is-retrieval-augmented-generation/index.mdx index eb67faf5094..5b65b10f082 100644 --- a/apps/sim/content/library/what-is-retrieval-augmented-generation/index.mdx +++ b/apps/sim/content/library/what-is-retrieval-augmented-generation/index.mdx @@ -3,25 +3,25 @@ slug: what-is-retrieval-augmented-generation title: 'What Is Retrieval-Augmented Generation (RAG)?' description: 'Learn how retrieval-augmented generation connects language models to current or private knowledge, how RAG compares with fine-tuning, and how agentic RAG works.' date: 2026-08-11 -updated: 2026-08-11 +updated: 2026-09-06 authors: - andrew -readingTime: 7 +readingTime: 6 tags: [RAG, AI Agents, Knowledge Bases, Sim] ogImage: /library/what-is-retrieval-augmented-generation/cover.jpg canonical: https://www.sim.ai/library/what-is-retrieval-augmented-generation draft: false faq: - q: "Is RAG a type of fine-tuning?" - a: "RAG retrieves external information without changing the model's weights, while fine-tuning updates those weights through training. Sim Knowledge Bases let an agent retrieve current or private information during a workflow, and you can update that information without retraining the model." + a: "RAG retrieves external information without changing the model's weights, while fine-tuning updates those weights through training. Sim Knowledge Bases let an agent retrieve current or private information during a workflow. You can update that information without retraining the model." - q: "Does RAG eliminate hallucinations?" - a: "RAG reduces hallucinations by grounding responses in retrieved context, but it cannot prevent every model error. A Sim agent can consult a Knowledge Base before answering or acting, and better retrieval gives the agent stronger evidence for its response." + a: "RAG can reduce hallucinations by grounding responses in retrieved context, but it cannot prevent every model error. A Sim agent can consult a Knowledge Base before answering or acting. Better retrieval gives the agent stronger evidence for its response." - q: "What is agentic RAG?" - a: "Agentic RAG lets an agent decide when to retrieve information and whether another search is necessary. Sim agents can call native Knowledge Bases during multi-step reasoning, supporting tasks that require several sources or revised queries." + a: "Agentic RAG lets an agent decide when to retrieve information and whether another search is necessary. Sim agents can call native Knowledge Bases during multi-step reasoning. Dynamic retrieval supports tasks that require several sources or revised queries." - q: "Can you use RAG and fine-tuning together?" a: "RAG and fine-tuning can work together because they address different needs. A fine-tuned model can control behavior or format, while a Sim Knowledge Base supplies current information. Combining them can provide consistent outputs without freezing changing facts into model weights." - q: "How much latency does RAG add?" - a: "RAG adds time for retrieval, prompt construction, and any reranking before generation. A Sim agent may add more latency when it performs several retrievals during one task. Actual latency depends on index size, retrieval infrastructure, context length, and the number of agent steps." + a: "RAG adds time for retrieval, prompt construction, and any reranking before generation. A Sim agent may add more latency when it performs several retrievals during one task. Index size and retrieval infrastructure affect search time, while context length and repeated agent steps add further processing time." --- ## TL;DR @@ -33,27 +33,27 @@ faq: ## What is retrieval-augmented generation? -Retrieval-augmented generation connects a frozen large language model to an external knowledge base when the model handles a request. RAG gives the model relevant information beyond its training data without changing its parameters through retraining. +Retrieval-augmented generation connects a large language model to an external knowledge base when the model handles a request. RAG gives the model relevant information beyond its training data without changing its parameters through retraining. A RAG request begins with a query and retrieval. A retriever searches the knowledge base for passages related to the user's request. The application then performs augmentation by adding those passages to the prompt, and the model completes generation using both the query and the retrieved context. [IBM describes RAG](https://www.ibm.com/think/topics/retrieval-augmented-generation) as an architecture that connects AI models with external knowledge bases to produce more relevant responses. -The knowledge base can contain private documents, product records, or current information that the model did not encounter during training. RAG therefore changes the context available for a specific request while leaving the underlying model unchanged. The following sections explain why models need that external context and how retrieval and generation work together. +The knowledge base can contain private documents, product records, or current information that the model did not encounter during training. RAG therefore changes the context available for a specific request while leaving the underlying model unchanged. External context helps the model answer questions about information that is private, current, or absent from its training data. -## Why RAG exists: the problem with relying on model memory alone +## Why models need retrieval -An LLM's internal knowledge stops at the cutoff for its training data. Events, policies, prices, and product details published after that point remain outside the model's memory. RAG gives the model access to current sources when it answers, so you can update the knowledge base without retraining the model. +An LLM does not reliably know information created after its training cutoff. New policies and product details may therefore be absent from its responses. RAG gives the model access to current sources when it answers, so you can update the knowledge base without retraining the model. -An LLM also lacks automatic access to private information. Company documents, customer records, and internal procedures do not become available unless an application supplies them as context. RAG retrieves relevant passages from approved sources and places them in the prompt. Grounding an answer in those passages can [reduce hallucinations](https://www.ibm.com/think/topics/retrieval-augmented-generation), though it cannot prevent every factual error. +An LLM also lacks automatic access to private information. Private company information remains unavailable unless an authorized application supplies it as context. RAG retrieves relevant passages from approved sources and places them in the prompt. Grounding an answer in those passages can [reduce hallucinations](https://www.ibm.com/think/topics/retrieval-augmented-generation), though it cannot prevent every factual error. Retrieval often costs less than repeatedly retraining a model as information changes. You can refresh documents or indexes while leaving the model itself unchanged. Fine-tuning offers another way to adapt a model, but it serves different needs and requires a separate decision about training cost, maintenance, and intended behavior. ## How the retrieval step and generation step work together -A RAG pipeline joins retrieval and generation by placing selected source material in the model's prompt before it writes an answer. Four components divide the work. The knowledge base stores source material, and the retriever finds relevant passages. The integration layer combines those passages with the user's query, and the generator produces the response. +A RAG pipeline joins retrieval and generation by placing selected source material in the model's prompt before it writes an answer. The pipeline stores source material in a searchable knowledge base and retrieves relevant passages for each query. It then adds those passages to the prompt before the model generates a response. -The knowledge base prepares documents for search before any query arrives. It splits each document into chunks and converts each chunk into a numerical representation called an embedding. [Chunk size affects retrieval quality](https://www.ibm.com/think/topics/retrieval-augmented-generation). Large chunks preserve more context but may mix relevant details with unrelated material, while small chunks offer greater precision but may separate a statement from the context needed to interpret it. +An ingestion pipeline prepares documents for search before any query arrives. It splits each document into chunks and commonly converts those chunks into numerical representations called embeddings. [Chunk size affects retrieval quality](https://www.ibm.com/think/topics/retrieval-augmented-generation). Large chunks preserve more context but may mix relevant details with unrelated material, while small chunks offer greater precision but may separate a statement from the context needed to interpret it. -The retriever searches by meaning rather than relying only on matching words. When a user submits a query, the retriever creates an embedding for it and compares that embedding with the stored chunk embeddings. Chunks with nearby representations rank as more semantically similar to the query. +A retriever can search by semantic similarity, keyword matching, or a combination of both. When a user submits a query, the retriever creates an embedding for it and compares that embedding with the stored chunk embeddings. In vector retrieval, chunks with nearby representations rank as more semantically similar to the query. The integration layer then inserts the top-ranked chunks into an augmented prompt alongside the original query and any response instructions. The generator reads that prompt and writes an answer using both its trained language capabilities and the retrieved material. Retrieval quality determines what evidence reaches the generator, while prompt construction determines how clearly the generator can use it. @@ -63,11 +63,11 @@ RAG fills a knowledge gap by retrieving external information when a request arri | Approach | Mechanism | Best use case | Knowledge currency | Latency and cost profile | Setup complexity | | --- | --- | --- | --- | --- | --- | -| RAG | Retrieves relevant chunks and adds them to the prompt | Private, changing, or source-backed knowledge | Updates when you refresh the external index | Adds retrieval latency but limits input tokens | Requires document processing, indexing, and retrieval evaluation | +| RAG | Retrieves relevant chunks and adds them to the prompt | Private, changing, or source-backed knowledge | Updates when you refresh the external index | Adds retrieval latency but can use fewer input tokens than supplying entire documents | Requires document processing, indexing, and retrieval evaluation | | Fine-tuning | Trains model weights on curated examples | Consistent behavior, style, format, or domain conventions | Remains fixed until another training run | Requires upfront training but can reduce inference latency | Requires training data, evaluation, versioning, and retraining | | Long-context prompting | Places whole documents or datasets in the context window | Summarization or analysis within one session | Depends on the material supplied with each request | Costs and latency rise as the prompt grows | Requires little infrastructure beyond prompt construction | -A practical [model-optimization sequence](https://platform.openai.com/docs/guides/model-optimization) starts with prompting. You can add RAG when the model lacks domain knowledge, then consider fine-tuning when prompting and retrieval still cannot produce the required behavior. +Start with prompting when the model already has the required knowledge. Add RAG when it needs external information, and consider fine-tuning when you need behavior that prompting and retrieval do not produce consistently. Production systems can combine these methods. A fine-tuned model can provide consistent behavior while RAG supplies current facts. RAG can also select relevant documents for a long-context model to analyze together. @@ -75,28 +75,28 @@ Production systems can combine these methods. A fine-tuned model can provide con Agentic RAG lets [an AI agent](https://www.sim.ai/library/what-is-an-ai-agent-definition-how-it-works-and-examples) retrieve evidence whenever a task requires it, including after reasoning has begun. Traditional RAG follows a fixed retrieve-once and generate-once sequence, so the model cannot correct an incomplete search. An [agentic retrieval loop](https://toloka.ai/blog/agentic-rag-systems-for-enterprise-scale-information-retrieval/) can revise queries, retrieve across multiple sources, and decide whether the available evidence supports an answer. -A planner first breaks the task into steps, and the agent then calls retrieval or other tools as needed. Memory carries useful findings into later steps. Reflection lets the agent inspect an intermediate result and search again when evidence conflicts or leaves a gap. +An agent can break a task into steps and call retrieval or [other tools exposed through an MCP server](https://www.sim.ai/library/what-is-an-mcp-server) when needed. It can retain useful findings for later steps and search again when the available evidence conflicts or leaves a gap. -For example, an agent reviewing a contract might retrieve the standard cancellation policy first. A clause in the contract could then prompt a second search for an account-specific amendment. Static RAG cannot plan the second query because the need for it appears only after the first document has been read. +For example, an agent reviewing a contract might retrieve the standard cancellation policy first. A clause in the contract could then prompt a second search for an account-specific amendment. A fixed retrieve-once pipeline would not issue the second query because the need for it appears only after the first document has been read. -[Sim's native Knowledge Bases](https://sim.ai) make retrieval a workspace resource that an Agent block can call during reasoning. Knowledge bases sit alongside workflow logic and other tools, including [tools exposed through an MCP server](https://www.sim.ai/library/what-is-an-mcp-server), rather than requiring a separate vector-store integration built around one LLM application. Compared with an [application-centered Dify setup](https://www.sim.ai/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform), Sim places retrieval inside an agent-native workspace where multiple workflow steps can use the same grounded context. +[Sim's native Knowledge Bases](https://sim.ai) make retrieval a workspace resource that an Agent block can call during reasoning. Knowledge bases sit alongside workflow logic and other tools, rather than requiring a separate vector-store integration built around one LLM application. [Dify can suit application-centered workflows](https://docs.dify.ai/en/cloud/use-dify/knowledge/integrate-knowledge-within-application), while Sim places retrieval inside an agent-native workspace so multiple workflow steps can query the same Knowledge Base. See the [Sim and Dify comparison](https://www.sim.ai/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform) for more context. -Sim's [Apache 2.0 licensing](https://www.sim.ai/library/apache-2-0-vs-fair-code) also supports self-hosting, which gives you control over the agent runtime and retrieval infrastructure. Agentic RAG still costs more than a single retrieval pass because every retry adds model work and latency. You can limit reasoning depth, cache common searches, and rerank retrieved passages when response time or usage cost requires tighter bounds. +Sim's [Apache 2.0 repository](https://github.com/simstudioai/sim) also supports self-hosting, which gives you control over the agent runtime and retrieval infrastructure. Agentic RAG still costs more than a single retrieval pass because every retry adds model work and latency. You can limit reasoning depth, cache common searches, and rerank retrieved passages when response time or usage cost requires tighter bounds. -## RAG's real tradeoffs +## RAG tradeoffs -RAG can produce a weak answer even when the source documents contain the right facts. Chunk boundaries can separate a claim from its context, while a poorly matched embedding model can retrieve related but irrelevant passages. You should evaluate retrieval separately from generation because a fluent model can conceal a poor retrieval result, which is one reason [agent observability](https://www.sim.ai/library/ai-agent-observability) matters in production. +RAG can produce a weak answer even when the source documents contain the right facts. Chunk boundaries can separate a claim from its context, while a poorly matched embedding model can retrieve related but irrelevant passages. You should evaluate retrieval separately from generation because a fluent model can conceal a poor retrieval result. [Agent observability](https://www.sim.ai/library/ai-agent-observability) can help you inspect that behavior in production. -Each retrieval step adds search, network, and prompt-processing time before generation begins. [Even millisecond-scale retrieval overhead can accumulate](https://www.meilisearch.com/blog/rag-vs-long-context-llms), especially when an agent performs several searches. Caching common queries can reduce latency, but cached results may sacrifice freshness. +Each retrieval step adds processing time before generation begins, including the time required to search the index and assemble the prompt. Retrieval latency can [accumulate across repeated searches](https://www.meilisearch.com/blog/rag-vs-long-context-llms), especially when an agent performs several of them. Caching common queries can reduce latency, but cached results may sacrifice freshness. A RAG index also needs an explicit update policy. [Knowledge bases lose relevance without continual updates](https://www.ibm.com/think/topics/retrieval-augmented-generation), so synchronization jobs must capture changed and deleted source material. Versioned indexes can help you test updates before they affect production answers. Vector stores extend the security boundary around private data. You should encrypt stored data and restrict retrieval according to the requesting user's permissions. An agent must never receive a chunk that the user could not open in its source system. -Production RAG requires measurable standards for retrieval accuracy and response time. Update schedules and access controls need the same deliberate planning. +Before deployment, define targets for retrieval accuracy and response time, then test whether index updates preserve permissions and remove deleted material. ## Next step: build a RAG-grounded agent -An agent should use retrieval as a workspace capability whenever its reasoning requires private or current information. Sim's native Knowledge Bases give Agent blocks access to grounded context during a workflow, without requiring a separate vector-store integration tied to one chat application. +Use retrieval as a workspace capability when an agent needs private or current information during a task. Sim's native Knowledge Bases give Agent blocks access to grounded context during a workflow, without requiring a separate vector-store integration tied to one chat application. -You can create the workflow with Mothership, inspect and edit its logic in the visual builder, or connect it through the API. [Start building a RAG-grounded agent in Sim](https://sim.ai). +You can create the workflow with Mothership, inspect and edit its logic in the visual builder, or connect it through the API. [Explore how to build a RAG-grounded agent in Sim](https://sim.ai). diff --git a/apps/sim/executor/dag/construction/edges.ts b/apps/sim/executor/dag/construction/edges.ts index 018c1b79251..afe4aac5671 100644 --- a/apps/sim/executor/dag/construction/edges.ts +++ b/apps/sim/executor/dag/construction/edges.ts @@ -75,6 +75,8 @@ export class EdgeConstructor { const routerV2ConfigMap = new Map() for (const block of workflow.blocks) { + if (block.enabled === false) continue + const blockType = block.metadata?.id ?? '' blockTypeMap.set(block.id, blockType) diff --git a/apps/sim/executor/execution/edge-manager.test.ts b/apps/sim/executor/execution/edge-manager.test.ts index 8b01100a63a..90d51e5d9dc 100644 --- a/apps/sim/executor/execution/edge-manager.test.ts +++ b/apps/sim/executor/execution/edge-manager.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from 'vitest' -import { EDGE } from '@/executor/constants' -import type { DAG, DAGNode } from '@/executor/dag/builder' +import { BlockType, EDGE } from '@/executor/constants' +import { type DAG, DAGBuilder, type DAGNode } from '@/executor/dag/builder' import type { DAGEdge } from '@/executor/dag/types' -import type { SerializedBlock } from '@/serializer/types' -import { EdgeManager } from './edge-manager' +import { EdgeManager } from '@/executor/execution/edge-manager' +import type { NormalizedBlockOutput } from '@/executor/types' +import { + buildBranchNodeId, + buildParallelSentinelEndId, + buildParallelSentinelStartId, + buildSentinelEndId, + buildSentinelStartId, +} from '@/executor/utils/subflow-utils' +import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' function createMockBlock(id: string): SerializedBlock { return { @@ -45,6 +53,188 @@ function createMockDAG(nodes: Map): DAG { } describe('EdgeManager', () => { + describe('Dead-end routing regressions', () => { + it.each(['loop', 'parallel'] as const)( + 'keeps an independently activated join waiting until the enclosing %s exits', + (subflowType) => { + const workflow: SerializedWorkflow = { + version: '1', + blocks: [ + { ...createMockBlock('trigger'), metadata: { id: BlockType.STARTER } }, + { + ...createMockBlock('subflow'), + metadata: { id: subflowType === 'loop' ? BlockType.LOOP : BlockType.PARALLEL }, + }, + { ...createMockBlock('condition'), metadata: { id: BlockType.CONDITION } }, + createMockBlock('skipped'), + createMockBlock('independent'), + createMockBlock('join'), + ], + connections: [ + { source: 'trigger', target: 'subflow' }, + { source: 'trigger', target: 'independent' }, + { + source: 'subflow', + target: 'condition', + sourceHandle: subflowType === 'loop' ? 'loop-start-source' : 'parallel-start-source', + }, + { source: 'condition', target: 'skipped', sourceHandle: 'condition-if' }, + { + source: 'subflow', + target: 'join', + sourceHandle: subflowType === 'loop' ? 'loop-end-source' : 'parallel-end-source', + }, + { source: 'independent', target: 'join' }, + ], + loops: + subflowType === 'loop' + ? { subflow: { id: 'subflow', nodes: ['condition', 'skipped'], iterations: 2 } } + : {}, + parallels: + subflowType === 'parallel' + ? { + subflow: { + id: 'subflow', + nodes: ['condition', 'skipped'], + count: 2, + parallelType: 'count', + }, + } + : {}, + } + const dag = new DAGBuilder().build(workflow, { triggerBlockId: 'trigger' }) + const edgeManager = new EdgeManager(dag) + const sentinelStartId = + subflowType === 'loop' + ? buildSentinelStartId('subflow') + : buildParallelSentinelStartId('subflow') + const sentinelEndId = + subflowType === 'loop' + ? buildSentinelEndId('subflow') + : buildParallelSentinelEndId('subflow') + const conditionId = subflowType === 'loop' ? 'condition' : buildBranchNodeId('condition', 0) + + edgeManager.processOutgoingEdges(dag.nodes.get('trigger')!, {}) + expect(edgeManager.processOutgoingEdges(dag.nodes.get('independent')!, {})).toEqual([]) + edgeManager.processOutgoingEdges(dag.nodes.get(sentinelStartId)!, { sentinelStart: true }) + + expect( + edgeManager.processOutgoingEdges(dag.nodes.get(conditionId)!, { selectedOption: 'else' }) + ).toEqual([sentinelEndId]) + expect(edgeManager.isNodeReady(dag.nodes.get('join')!)).toBe(false) + + expect( + edgeManager.processOutgoingEdges(dag.nodes.get(sentinelEndId)!, { + selectedRoute: subflowType === 'loop' ? EDGE.LOOP_CONTINUE : EDGE.PARALLEL_CONTINUE, + }) + ).toEqual([sentinelStartId]) + expect(edgeManager.isNodeReady(dag.nodes.get('join')!)).toBe(false) + + expect( + edgeManager.processOutgoingEdges(dag.nodes.get(sentinelEndId)!, { + selectedRoute: subflowType === 'loop' ? EDGE.LOOP_EXIT : EDGE.PARALLEL_EXIT, + }) + ).toEqual(['join']) + } + ) + + it.each([ + { handle: 'condition-else', output: { selectedOption: 'if' } }, + { handle: 'router-other', output: { selectedRoute: 'selected' } }, + ])('releases an activated join after cascading through $handle', ({ handle, output }) => { + const condition = createMockNode('decision', [{ target: 'skipped', sourceHandle: handle }]) + const skipped = createMockNode('skipped', [{ target: 'join' }], ['decision']) + const independent = createMockNode('independent', [{ target: 'join' }]) + const join = createMockNode('join', [], ['skipped', 'independent']) + const dag = createMockDAG( + new Map([ + ['decision', condition], + ['skipped', skipped], + ['independent', independent], + ['join', join], + ]) + ) + const edgeManager = new EdgeManager(dag) + + expect(edgeManager.processOutgoingEdges(independent, {})).toEqual([]) + expect(edgeManager.processOutgoingEdges(condition, output as NormalizedBlockOutput)).toEqual([ + 'join', + ]) + expect(edgeManager.hasActivatedEdge('skipped')).toBe(false) + }) + + it('releases an activated join when another branch remains executable', () => { + const decision = createMockNode('decision', [ + { target: 'skipped', sourceHandle: 'condition-else' }, + { target: 'selected', sourceHandle: 'condition-if' }, + ]) + const skipped = createMockNode('skipped', [{ target: 'join' }], ['decision']) + const selected = createMockNode('selected', [], ['decision']) + const independent = createMockNode('independent', [{ target: 'join' }]) + const join = createMockNode('join', [], ['skipped', 'independent']) + const dag = createMockDAG( + new Map([ + ['decision', decision], + ['skipped', skipped], + ['selected', selected], + ['independent', independent], + ['join', join], + ]) + ) + const edgeManager = new EdgeManager(dag) + + expect(edgeManager.processOutgoingEdges(independent, {})).toEqual([]) + expect(edgeManager.processOutgoingEdges(decision, { selectedOption: 'if' })).toEqual([ + 'selected', + 'join', + ]) + }) + + it.each(['loop', 'parallel'] as const)( + 'releases an independently activated join when an entire downstream %s is skipped', + (subflowType) => { + const decision = createMockNode('decision', [ + { target: 'subflow-start', sourceHandle: 'condition-if' }, + ]) + const start = createMockNode('subflow-start', [{ target: 'body' }], ['decision']) + const body = createMockNode('body', [{ target: 'subflow-end' }], ['subflow-start']) + const end = createMockNode( + 'subflow-end', + [ + { + target: 'subflow-start', + sourceHandle: subflowType === 'loop' ? EDGE.LOOP_CONTINUE : EDGE.PARALLEL_CONTINUE, + }, + { + target: 'join', + sourceHandle: subflowType === 'loop' ? EDGE.LOOP_EXIT : EDGE.PARALLEL_EXIT, + }, + ], + ['body'] + ) + end.metadata = { + isSentinel: true, + sentinelType: 'end', + subflowType, + subflowId: 'skipped-subflow', + } + const independent = createMockNode('independent', [{ target: 'join' }]) + const join = createMockNode('join', [], ['subflow-end', 'independent']) + const edgeManager = new EdgeManager( + createMockDAG( + new Map([decision, start, body, end, independent, join].map((node) => [node.id, node])) + ) + ) + + expect(edgeManager.processOutgoingEdges(independent, {})).toEqual([]) + expect(edgeManager.processOutgoingEdges(decision, { selectedOption: 'else' })).toEqual([ + 'join', + ]) + expect(edgeManager.hasActivatedEdge(start.id)).toBe(false) + } + ) + }) + describe('Happy path - basic workflows', () => { it('should handle simple linear flow (A → B → C)', () => { const blockAId = 'block-a' diff --git a/apps/sim/executor/execution/edge-manager.ts b/apps/sim/executor/execution/edge-manager.ts index ecd33472b11..d0911492db6 100644 --- a/apps/sim/executor/execution/edge-manager.ts +++ b/apps/sim/executor/execution/edge-manager.ts @@ -72,10 +72,20 @@ export class EdgeManager { const isDeadEnd = activatedTargets.length === 0 const isRoutedDeadEnd = isDeadEnd && !!(output.selectedOption || output.selectedRoute) + const isSubflowExit = + output.selectedRoute === EDGE.LOOP_EXIT || output.selectedRoute === EDGE.PARALLEL_EXIT for (const targetId of cascadeTargets) { if (!readyNodes.includes(targetId) && !activatedTargets.includes(targetId)) { - if (!isDeadEnd || !this.isTargetReady(targetId)) continue + if (!this.isTargetReady(targetId)) continue + + /** A previously activated join can become ready several edges into a skipped branch. */ + if (!isSubflowExit && this.nodesWithActivatedEdge.has(targetId)) { + readyNodes.push(targetId) + continue + } + + if (!isDeadEnd) continue if (isRoutedDeadEnd) { // A condition/router deliberately selected a dead-end path. @@ -92,7 +102,7 @@ export class EdgeManager { } } - if (output.selectedRoute !== EDGE.LOOP_EXIT && output.selectedRoute !== EDGE.PARALLEL_EXIT) { + if (!isSubflowExit) { for (const { target } of edgesToDeactivate) { if ( !readyNodes.includes(target) && @@ -308,7 +318,8 @@ export class EdgeManager { targetId: string, sourceHandle?: string, cascadeTargets?: Set, - isCascade = false + isCascade = false, + cascadeSourceId = sourceId ): void { const edgeKey = this.createEdgeKey(sourceId, targetId, sourceHandle) if (this.deactivatedEdges.has(edgeKey)) { @@ -320,10 +331,23 @@ export class EdgeManager { const targetNode = this.dag.nodes.get(targetId) if (!targetNode) return - if (isCascade && this.isTerminalControlNode(targetId)) { + if ( + isCascade && + (this.isTerminalControlNode(targetId) || this.nodesWithActivatedEdge.has(targetId)) + ) { cascadeTargets?.add(targetId) } + /** The enclosing subflow must resolve its own exit before downstream joins become ready. */ + const cascadeSourceNode = this.dag.nodes.get(cascadeSourceId) + if ( + targetNode.metadata.sentinelType === 'end' && + cascadeSourceNode && + this.isEnclosingSentinel(cascadeSourceNode, targetId) + ) { + return + } + // Don't cascade if node has active incoming edges OR has received an activated edge if ( this.hasActiveIncomingEdges(targetNode, edgeKey) || @@ -339,7 +363,8 @@ export class EdgeManager { outgoingEdge.target, outgoingEdge.sourceHandle, cascadeTargets, - true + true, + cascadeSourceId ) } } diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts index f7db2aa7846..7ef19b5c938 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.test.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts @@ -5,8 +5,17 @@ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { NonRetryableExecutionError } from '@/lib/execution/non-retryable-error' import { BlockType } from '@/executor/constants' +import { DAGBuilder } from '@/executor/dag/builder' +import { EdgeManager } from '@/executor/execution/edge-manager' import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler' -import type { BlockState, ExecutionContext } from '@/executor/types' +import type { BlockState, ExecutionContext, NormalizedBlockOutput } from '@/executor/types' +import { + buildBranchNodeId, + buildParallelSentinelEndId, + buildParallelSentinelStartId, + buildSentinelEndId, + buildSentinelStartId, +} from '@/executor/utils/subflow-utils' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' vi.mock('@/tools', () => ({ @@ -458,6 +467,204 @@ describe('ConditionBlockHandler', () => { await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( `Target block ${mockTargetBlock1.id} not found` ) + expect(mockExecuteTool).toHaveBeenCalledOnce() + expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false) + }) + + it('preserves routing metadata when the target block is disabled', async () => { + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) + + const conditions = [{ id: 'cond1', title: 'if', value: 'true' }] + const inputs = { conditions: JSON.stringify(conditions) } + + mockTargetBlock1.enabled = false + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result).toEqual({ + value: 10, + text: 'hello', + conditionResult: true, + selectedOption: 'cond1', + selectedPath: { + blockId: mockTargetBlock1.id, + blockType: 'target', + blockTitle: 'Target Block 1', + }, + }) + expect(mockExecuteTool).toHaveBeenCalledOnce() + expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1') + }) + + describe('Dead-end routing through the DAG', () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'true' }, + { id: 'else1', title: 'else', value: '' }, + ] + + it('does not activate the else branch when the matching target is disabled', async () => { + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) + mockTargetBlock1.enabled = false + const workflow: SerializedWorkflow = { + ...mockContext.workflow!, + version: '1', + loops: {}, + } + const dag = new DAGBuilder().build(workflow, { triggerBlockId: mockSourceBlock.id }) + const edgeManager = new EdgeManager(dag) + + const output = await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(conditions), + }) + const readyNodes = edgeManager.processOutgoingEdges( + dag.nodes.get(mockBlock.id)!, + output as NormalizedBlockOutput + ) + + expect(dag.nodes.has(mockTargetBlock1.id)).toBe(false) + expect(readyNodes).toEqual([]) + expect(edgeManager.hasActivatedEdge(mockTargetBlock2.id)).toBe(false) + expect(output).toMatchObject({ + selectedOption: 'cond1', + selectedPath: { blockId: mockTargetBlock1.id }, + }) + expect(mockExecuteTool).toHaveBeenCalledOnce() + }) + + it('records a disabled else branch without evaluating an expression', async () => { + mockTargetBlock2.enabled = false + + const output = await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify([conditions[1]]), + }) + + expect(output).toMatchObject({ + conditionResult: true, + selectedOption: 'else1', + selectedPath: { blockId: mockTargetBlock2.id }, + }) + expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1') + expect(mockExecuteTool).not.toHaveBeenCalled() + }) + + it.each(['loop', 'parallel'] as const)( + 'completes the enclosing %s when the selected target is disabled', + async (subflowType) => { + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) + mockTargetBlock1.enabled = false + const subflowId = 'enclosing-subflow' + const subflowBlock: SerializedBlock = { + ...mockSourceBlock, + id: subflowId, + metadata: { id: subflowType === 'loop' ? BlockType.LOOP : BlockType.PARALLEL }, + } + const nodes = [mockBlock.id, mockTargetBlock1.id, mockTargetBlock2.id] + const workflow: SerializedWorkflow = { + version: '1', + blocks: [...mockContext.workflow!.blocks, subflowBlock], + connections: [ + { source: mockSourceBlock.id, target: subflowId }, + { + source: subflowId, + target: mockBlock.id, + sourceHandle: subflowType === 'loop' ? 'loop-start-source' : 'parallel-start-source', + }, + ...mockContext.workflow!.connections.filter((edge) => edge.source === mockBlock.id), + ], + loops: + subflowType === 'loop' ? { [subflowId]: { id: subflowId, nodes, iterations: 2 } } : {}, + parallels: + subflowType === 'parallel' + ? { [subflowId]: { id: subflowId, nodes, count: 2, parallelType: 'count' } } + : {}, + } + mockContext.workflow = workflow + const dag = new DAGBuilder().build(workflow, { triggerBlockId: mockSourceBlock.id }) + const edgeManager = new EdgeManager(dag) + const conditionNodeId = + subflowType === 'loop' ? mockBlock.id : buildBranchNodeId(mockBlock.id, 0) + const sentinelStartId = + subflowType === 'loop' + ? buildSentinelStartId(subflowId) + : buildParallelSentinelStartId(subflowId) + const sentinelEndId = + subflowType === 'loop' + ? buildSentinelEndId(subflowId) + : buildParallelSentinelEndId(subflowId) + const conditionNode = dag.nodes.get(conditionNodeId)! + mockContext.currentVirtualBlockId = conditionNodeId + edgeManager.processOutgoingEdges(dag.nodes.get(mockSourceBlock.id)!, {}) + const readyAfterStart = edgeManager.processOutgoingEdges( + dag.nodes.get(sentinelStartId)!, + {} + ) + + const output = await handler.execute(mockContext, conditionNode.block, { + conditions: JSON.stringify(conditions), + }) + const readyAfterCondition = edgeManager.processOutgoingEdges( + conditionNode, + output as NormalizedBlockOutput + ) + + expect(readyAfterStart).toContain(conditionNodeId) + expect(readyAfterCondition).toEqual([sentinelEndId]) + expect(mockContext.decisions.condition.get(conditionNodeId)).toBe('cond1') + expect(output).toMatchObject({ + selectedOption: 'cond1', + selectedPath: { blockId: mockTargetBlock1.id }, + }) + } + ) + + it.each(['before', 'after'] as const)( + 'releases a join whose independent path completes %s the dead-end condition', + async (independentPathOrder) => { + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) + mockTargetBlock1.enabled = false + const independentBlock: SerializedBlock = { ...mockSourceBlock, id: 'independent' } + const joinBlock: SerializedBlock = { ...mockTargetBlock2, id: 'join' } + const workflow: SerializedWorkflow = { + version: '1', + loops: {}, + blocks: [...mockContext.workflow!.blocks, independentBlock, joinBlock], + connections: [ + ...mockContext.workflow!.connections, + { source: mockSourceBlock.id, target: independentBlock.id }, + { source: independentBlock.id, target: joinBlock.id }, + { source: mockTargetBlock2.id, target: joinBlock.id }, + ], + } + mockContext.workflow = workflow + const dag = new DAGBuilder().build(workflow, { triggerBlockId: mockSourceBlock.id }) + const edgeManager = new EdgeManager(dag) + edgeManager.processOutgoingEdges(dag.nodes.get(mockSourceBlock.id)!, {}) + const readyNodes: string[] = [] + if (independentPathOrder === 'before') { + readyNodes.push( + ...edgeManager.processOutgoingEdges(dag.nodes.get(independentBlock.id)!, {}) + ) + } + + const output = await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(conditions), + }) + readyNodes.push( + ...edgeManager.processOutgoingEdges( + dag.nodes.get(mockBlock.id)!, + output as NormalizedBlockOutput + ) + ) + if (independentPathOrder === 'after') { + readyNodes.push( + ...edgeManager.processOutgoingEdges(dag.nodes.get(independentBlock.id)!, {}) + ) + } + + expect(readyNodes).toEqual([joinBlock.id]) + expect(edgeManager.hasActivatedEdge(mockTargetBlock2.id)).toBe(false) + } + ) }) it('should return no-match result if no condition matches and no else exists', async () => { diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 87049e09bf9..b69d6f4cf02 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -438,14 +438,114 @@ describe('RouterBlockHandler', () => { expect(mockExecuteProviderRequest).not.toHaveBeenCalled() }) - it('should throw error if target block is missing', async () => { - const inputs = { prompt: 'Test' } - mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2] + it.each([true, false])( + 'rejects a missing target before choosing another route when an enabled sibling exists: %s', + async (hasEnabledSibling) => { + mockContext.workflow!.blocks = hasEnabledSibling ? [mockBlock, mockTargetBlock2] : [mockBlock] + + await expect( + handler.execute(mockContext, mockBlock, { prompt: 'Test', model: 'sim-auto' }) + ).rejects.toThrow('Target block target-block-1 not found') + expect(mockGenerateRouterPrompt).not.toHaveBeenCalled() + expect(mockResolveAutoModel).not.toHaveBeenCalled() + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + } + ) - await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( - 'Target block target-block-1 not found' - ) - expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + it('keeps targets enabled by default for older serialized workflows', async () => { + Reflect.deleteProperty(mockTargetBlock1, 'enabled') + + const result = await handler.execute(mockContext, mockBlock, { prompt: 'Test' }) + + expect(mockGenerateRouterPrompt).toHaveBeenCalledWith('Test', [ + expect.objectContaining({ id: 'target-block-1' }), + expect.objectContaining({ id: 'target-block-2' }), + ]) + expect(result).toMatchObject({ selectedRoute: 'target-block-1' }) + }) + + it('preserves existing error-edge routing candidates and decisions', async () => { + mockContext.workflow!.connections = [ + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'source-right' }, + { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'error' }, + ] + mockExecuteProviderRequest.mockResolvedValueOnce({ + content: 'target-block-2', + model: 'mock-model', + }) + + const result = await handler.execute(mockContext, mockBlock, { prompt: 'Test' }) + + expect(mockGenerateRouterPrompt).toHaveBeenCalledWith('Test', [ + expect.objectContaining({ id: 'target-block-1' }), + expect.objectContaining({ id: 'target-block-2' }), + ]) + expect(result).toMatchObject({ + selectedRoute: 'target-block-2', + selectedPath: { + blockId: 'target-block-2', + blockType: 'target', + blockTitle: 'Option B', + }, + }) + }) + + it.each([true, false])( + 'preserves a disabled routing decision when the other target enabled state is %s', + async (otherTargetEnabled) => { + mockTargetBlock1.enabled = false + mockTargetBlock2.enabled = otherTargetEnabled + + const result = await handler.execute(mockContext, mockBlock, { prompt: 'Test' }) + + expect(mockGenerateRouterPrompt).toHaveBeenCalledWith('Test', [ + expect.objectContaining({ id: 'target-block-1' }), + expect.objectContaining({ id: 'target-block-2' }), + ]) + expect(result).toMatchObject({ + selectedRoute: 'target-block-1', + selectedPath: { + blockId: 'target-block-1', + blockType: 'target', + blockTitle: 'Option A', + }, + }) + expect(result).not.toHaveProperty('error') + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + } + ) + + it('resolves sim-auto and preserves provider billing with existing routing candidates', async () => { + mockExecuteProviderRequest.mockResolvedValueOnce({ + content: 'target-block-2', + model: 'fireworks/glm-5.2', + tokens: { input: 100, output: 20, total: 120 }, + cost: { input: 0.001, output: 0.0005, total: 0.0015 }, + }) + + const result = await handler.execute(mockContext, mockBlock, { + prompt: 'Choose the best option.', + model: 'sim-auto', + }) + + expect(mockResolveAutoModel).toHaveBeenCalledWith({ + ctx: mockContext, + blockId: mockBlock.id, + signals: expect.objectContaining({ + lastMessage: 'Choose the best option.', + hasResponseFormat: false, + }), + fallbackModel: 'claude-sonnet-5', + }) + expect(providerRequestBody()).toMatchObject({ + model: 'fireworks/glm-5.2', + systemPrompt: 'Sim auto system preamble\n\nGenerated System Prompt', + }) + expect(result).toMatchObject({ + model: 'sim-auto', + selectedRoute: 'target-block-2', + cost: { input: 0.001, output: 0.0005, routing: 0.002, total: 0.0035 }, + }) }) it('should throw error if LLM response is not a valid target block ID', async () => { diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 4b9bcb9531d..1f983bf1339 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -207,14 +207,10 @@ async function fetchChatDeploymentStatus( workflowId: string, signal?: AbortSignal ): Promise { - const data = await requestJson(getChatDeploymentStatusContract, { + return requestJson(getChatDeploymentStatusContract, { params: { id: workflowId }, signal, }) - return { - isDeployed: data.isDeployed ?? false, - deployment: data.deployment ?? null, - } } /** diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index e710d9e9ae5..b8bc3f94713 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -221,6 +221,21 @@ export const deploymentVersionsResponseSchema = z.object({ export type DeploymentVersionsResponse = z.output +/** + * Zod's default strip, deliberately not `.passthrough()`. + * + * The route builder responds with `schema.parse(body)`, so stripping is what + * holds this `read`-level status response to its narrow projection: a presenter + * that later widens it into the admin-gated detail fields cannot put them on + * the wire. `.strict()` would instead throw, and since `requestJson` parses with + * this same schema, a new bundle reading an older pod's wider payload mid + * rollout would take the whole chat tab down with it. + * + * Stripping is silent, so it is the last line rather than the only one: + * `WorkflowChatDeploymentStatus` types the projection at its source, and + * widening it needs a cast the boundary audit already refuses. See + * `readWorkflowChatDeploymentStatus` for why the projection is this narrow. + */ export const chatDeploymentStatusSchema = z.object({ isDeployed: z.boolean(), deployment: z @@ -228,7 +243,6 @@ export const chatDeploymentStatusSchema = z.object({ id: z.string(), identifier: z.string(), }) - .passthrough() .nullable(), }) diff --git a/apps/sim/lib/api/contracts/tools/quickbooks.ts b/apps/sim/lib/api/contracts/tools/quickbooks.ts index 394b14d42b6..1690471d2db 100644 --- a/apps/sim/lib/api/contracts/tools/quickbooks.ts +++ b/apps/sim/lib/api/contracts/tools/quickbooks.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { userFileSchema } from '@/lib/api/contracts/primitives' +import type { ContractBody, ContractJsonResponse } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' @@ -198,3 +199,356 @@ export const quickBooksAddAttachmentContract = defineRouteContract({ ]), }, }) + +const QUICKBOOKS_MAX_LINES = 100 +const QUICKBOOKS_MAX_ALLOCATIONS = 100 + +function requiredQuickBooksId(label: string) { + return z.string().min(1, `${label} is required`).max(256, `${label} is too long`) +} + +function optionalQuickBooksId(label: string) { + return z.string().max(256, `${label} is too long`).optional() +} + +function optionalQuickBooksText(label: string, max: number) { + return z.string().max(max, `${label} is too long`).optional() +} + +/** + * QuickBooks dates are `YYYY-MM-DD`, but the format check stays in + * `validateQuickBooksDate` so an empty value keeps meaning "not supplied". + */ +function optionalQuickBooksDate(label: string) { + return z.string().max(32, `${label} is too long`).optional() +} + +const quickBooksActiveStatusSchema = z + .enum(['unchanged', 'active', 'inactive'], { + error: 'activeStatus must be unchanged, active, or inactive', + }) + .optional() + +/** + * Address input reaches this boundary already parsed into an object on every + * caller path. Key names and their QuickBooks mapping stay in + * `parseQuickBooksAddress`. + */ +const quickBooksAddressInputSchema = z.record(z.string(), z.string()) + +const quickBooksSalesLineInputSchema = z.strictObject({ + lineType: z.enum(['item', 'description'], { + error: 'lines[].lineType must be item or description', + }), + amount: z.number().optional(), + itemId: optionalQuickBooksId('lines[].itemId'), + description: optionalQuickBooksText('lines[].description', 4000), + quantity: z.number().optional(), + unitPrice: z.number().optional(), + serviceDate: optionalQuickBooksDate('lines[].serviceDate'), +}) + +const quickBooksSalesLinesSchema = z + .array(quickBooksSalesLineInputSchema) + .min(1, 'lines must contain at least one line') + .max(QUICKBOOKS_MAX_LINES, `lines cannot contain more than ${QUICKBOOKS_MAX_LINES} lines`) + +const quickBooksInvoiceAllocationsSchema = z + .array( + z.strictObject({ + invoiceId: requiredQuickBooksId('invoiceAllocations[].invoiceId'), + amount: z.number(), + }) + ) + .min(1, 'invoiceAllocations must contain at least one allocation') + .max( + QUICKBOOKS_MAX_ALLOCATIONS, + `invoiceAllocations cannot contain more than ${QUICKBOOKS_MAX_ALLOCATIONS} allocations` + ) + +const quickBooksBillAllocationsSchema = z + .array( + z.strictObject({ + billId: requiredQuickBooksId('billAllocations[].billId'), + amount: z.number(), + }) + ) + .min(1, 'billAllocations must contain at least one allocation') + .max( + QUICKBOOKS_MAX_ALLOCATIONS, + `billAllocations cannot contain more than ${QUICKBOOKS_MAX_ALLOCATIONS} allocations` + ) + +/** Every QuickBooks create/update operation answers with the same mutation envelope. */ +const quickBooksMutationResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + record: z + .object({ + Id: z.string().min(1), + SyncToken: z.string().optional(), + }) + .passthrough(), + recordId: boundedId, + syncToken: z.string().min(1), + recordVersion: z.string().min(1), + time: z.string().nullable(), + }), +}) + +const quickBooksMutationResponse = { + mode: 'json', + schema: quickBooksMutationResponseSchema, +} as const + +export const quickBooksCreateBillPaymentBodySchema = quickBooksAuthSchema.extend({ + vendorId: requiredQuickBooksId('vendorId'), + totalAmount: z.number(), + paymentType: z.enum(['check', 'credit_card'], { + error: 'paymentType must be check or credit_card', + }), + paymentAccountId: requiredQuickBooksId('paymentAccountId'), + billAllocations: quickBooksBillAllocationsSchema.optional(), + transactionDate: optionalQuickBooksDate('transactionDate'), + apAccountId: optionalQuickBooksId('apAccountId'), + currencyCode: optionalQuickBooksText('currencyCode', 8), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), + requestId: optionalQuickBooksText('requestId', 256), +}) + +export const quickBooksUpdateBillBodySchema = quickBooksAuthSchema.extend({ + billId: requiredQuickBooksId('billId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + apAccountId: optionalQuickBooksId('apAccountId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + dueDate: optionalQuickBooksDate('dueDate'), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +export const quickBooksUpdateBillPaymentBodySchema = quickBooksAuthSchema.extend({ + billPaymentId: requiredQuickBooksId('billPaymentId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +/** Credit memos and refund receipts share Intuit's sales-document update shape. */ +export const quickBooksUpdateSalesDocumentBodySchema = quickBooksAuthSchema.extend({ + transactionId: requiredQuickBooksId('transactionId'), + syncToken: requiredQuickBooksId('syncToken'), + customerId: optionalQuickBooksId('customerId'), + lines: quickBooksSalesLinesSchema.optional(), + transactionDate: optionalQuickBooksDate('transactionDate'), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), + customerMemo: optionalQuickBooksText('customerMemo', 4000), + dueDate: optionalQuickBooksDate('dueDate'), + expirationDate: optionalQuickBooksDate('expirationDate'), + paymentMethodId: optionalQuickBooksId('paymentMethodId'), + paymentReferenceNumber: optionalQuickBooksText('paymentReferenceNumber', 256), + depositAccountId: optionalQuickBooksId('depositAccountId'), +}) + +export const quickBooksUpdateCustomerPaymentBodySchema = quickBooksAuthSchema.extend({ + paymentId: requiredQuickBooksId('paymentId'), + syncToken: requiredQuickBooksId('syncToken'), + customerId: optionalQuickBooksId('customerId'), + totalAmount: z.number().optional(), + transactionDate: optionalQuickBooksDate('transactionDate'), + privateNote: optionalQuickBooksText('privateNote', 4000), + paymentReferenceNumber: optionalQuickBooksText('paymentReferenceNumber', 256), + paymentMethodId: optionalQuickBooksId('paymentMethodId'), + depositAccountId: optionalQuickBooksId('depositAccountId'), + invoiceAllocations: quickBooksInvoiceAllocationsSchema.optional(), + unapplyOmittedInvoices: z.boolean().optional(), +}) + +export const quickBooksUpdateEmployeeBodySchema = quickBooksAuthSchema.extend({ + employeeId: requiredQuickBooksId('employeeId'), + syncToken: requiredQuickBooksId('syncToken'), + displayName: optionalQuickBooksText('displayName', 1000), + givenName: optionalQuickBooksText('givenName', 1000), + familyName: optionalQuickBooksText('familyName', 1000), + primaryEmail: optionalQuickBooksText('primaryEmail', 320), + primaryPhone: optionalQuickBooksText('primaryPhone', 100), + primaryAddress: quickBooksAddressInputSchema.optional(), + printOnCheckName: optionalQuickBooksText('printOnCheckName', 1000), + billableTime: z.boolean().optional(), + activeStatus: quickBooksActiveStatusSchema, +}) + +export const quickBooksUpdateItemBodySchema = quickBooksAuthSchema.extend({ + itemId: requiredQuickBooksId('itemId'), + syncToken: requiredQuickBooksId('syncToken'), + name: optionalQuickBooksText('name', 1000), + incomeAccountId: optionalQuickBooksId('incomeAccountId'), + description: optionalQuickBooksText('description', 4000), + unitPrice: z.number().optional(), + purchaseDescription: optionalQuickBooksText('purchaseDescription', 4000), + purchaseCost: z.number().optional(), + expenseAccountId: optionalQuickBooksId('expenseAccountId'), + taxable: z.boolean().optional(), + activeStatus: quickBooksActiveStatusSchema, +}) + +export const quickBooksUpdatePurchaseBodySchema = quickBooksAuthSchema.extend({ + purchaseId: requiredQuickBooksId('purchaseId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + paymentReference: optionalQuickBooksText('paymentReference', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +export const quickBooksUpdatePurchaseOrderBodySchema = quickBooksAuthSchema.extend({ + purchaseOrderId: requiredQuickBooksId('purchaseOrderId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + apAccountId: optionalQuickBooksId('apAccountId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + dueDate: optionalQuickBooksDate('dueDate'), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +export const quickBooksUpdateVendorBodySchema = quickBooksAuthSchema.extend({ + vendorId: requiredQuickBooksId('vendorId'), + syncToken: requiredQuickBooksId('syncToken'), + displayName: optionalQuickBooksText('displayName', 1000), + companyName: optionalQuickBooksText('companyName', 1000), + givenName: optionalQuickBooksText('givenName', 1000), + familyName: optionalQuickBooksText('familyName', 1000), + primaryEmail: optionalQuickBooksText('primaryEmail', 320), + primaryPhone: optionalQuickBooksText('primaryPhone', 100), + billingAddress: quickBooksAddressInputSchema.optional(), + printOnCheckName: optionalQuickBooksText('printOnCheckName', 1000), + accountNumber: optionalQuickBooksText('accountNumber', 256), + vendor1099: z.boolean().optional(), + activeStatus: quickBooksActiveStatusSchema, +}) + +export const quickBooksUpdateVendorCreditBodySchema = quickBooksAuthSchema.extend({ + vendorCreditId: requiredQuickBooksId('vendorCreditId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + apAccountId: optionalQuickBooksId('apAccountId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +export const quickBooksCreateBillPaymentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/create-bill-payment', + body: quickBooksCreateBillPaymentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateBillContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-bill', + body: quickBooksUpdateBillBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateBillPaymentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-bill-payment', + body: quickBooksUpdateBillPaymentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateCreditMemoContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-credit-memo', + body: quickBooksUpdateSalesDocumentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateCustomerPaymentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-customer-payment', + body: quickBooksUpdateCustomerPaymentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateEmployeeContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-employee', + body: quickBooksUpdateEmployeeBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateItemContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-item', + body: quickBooksUpdateItemBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdatePurchaseContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-purchase', + body: quickBooksUpdatePurchaseBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdatePurchaseOrderContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-purchase-order', + body: quickBooksUpdatePurchaseOrderBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateRefundReceiptContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-refund-receipt', + body: quickBooksUpdateSalesDocumentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateVendorContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-vendor', + body: quickBooksUpdateVendorBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateVendorCreditContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-vendor-credit', + body: quickBooksUpdateVendorCreditBodySchema, + response: quickBooksMutationResponse, +}) + +export type QuickBooksCreateBillPaymentBody = ContractBody< + typeof quickBooksCreateBillPaymentContract +> +export type QuickBooksUpdateBillBody = ContractBody +export type QuickBooksUpdateBillPaymentBody = ContractBody< + typeof quickBooksUpdateBillPaymentContract +> +export type QuickBooksUpdateCreditMemoBody = ContractBody +export type QuickBooksUpdateCustomerPaymentBody = ContractBody< + typeof quickBooksUpdateCustomerPaymentContract +> +export type QuickBooksUpdateEmployeeBody = ContractBody +export type QuickBooksUpdateItemBody = ContractBody +export type QuickBooksUpdatePurchaseBody = ContractBody +export type QuickBooksUpdatePurchaseOrderBody = ContractBody< + typeof quickBooksUpdatePurchaseOrderContract +> +export type QuickBooksUpdateRefundReceiptBody = ContractBody< + typeof quickBooksUpdateRefundReceiptContract +> +export type QuickBooksUpdateVendorBody = ContractBody +export type QuickBooksUpdateVendorCreditBody = ContractBody< + typeof quickBooksUpdateVendorCreditContract +> +export type QuickBooksMutationOperationResponse = ContractJsonResponse< + typeof quickBooksUpdateVendorContract +> diff --git a/apps/sim/lib/api/contracts/webhooks.ts b/apps/sim/lib/api/contracts/webhooks.ts index b2c0b04ba79..25597f9281f 100644 --- a/apps/sim/lib/api/contracts/webhooks.ts +++ b/apps/sim/lib/api/contracts/webhooks.ts @@ -359,7 +359,13 @@ export const quickBooksWebhookEventSchema = z.object({ data: z.unknown().optional(), }) -export const quickBooksWebhookEventsSchema = z.array(quickBooksWebhookEventSchema).min(1).max(1000) +/** Maximum CloudEvents Intuit batches into a single webhook delivery. */ +export const QUICKBOOKS_WEBHOOK_MAX_EVENTS = 1000 + +export const quickBooksWebhookEventsSchema = z + .array(quickBooksWebhookEventSchema) + .min(1) + .max(QUICKBOOKS_WEBHOOK_MAX_EVENTS) export type QuickBooksWebhookEvent = z.input diff --git a/apps/sim/lib/chat-deployments/application/index.ts b/apps/sim/lib/chat-deployments/application/index.ts index 8a2e35aaf34..cbae80babf5 100644 --- a/apps/sim/lib/chat-deployments/application/index.ts +++ b/apps/sim/lib/chat-deployments/application/index.ts @@ -35,7 +35,9 @@ export { deleteWorkflowChatDeployment, type ReplaceWorkflowChatDeploymentInput, readWorkflowChatDeployment, + readWorkflowChatDeploymentStatus, replaceWorkflowChatDeployment, type WorkflowChatDeploymentInput, type WorkflowChatDeploymentResult, + type WorkflowChatDeploymentStatus, } from '@/lib/chat-deployments/application/workflow-chat-deployment' diff --git a/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts b/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts index 5ed60936a85..122f83b8311 100644 --- a/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts +++ b/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts @@ -104,6 +104,44 @@ export const readWorkflowChatDeployment = defineAuthorizedWorkspaceUseCase({ }, }) +export interface WorkflowChatDeploymentStatus { + isDeployed: boolean + /** Enough to address the deployment, and nothing the detail read gates. */ + deployment: { id: string; identifier: string } | null +} + +/** + * Whether the workflow publishes a chat, for the editor's deploy affordance. + * + * Bound to `chat_deployments.list`, not `chat_deployments.read`, and narrowed + * here in the use case rather than in the adapter. The editor needs to know a + * chat exists and which one it is so it can then fetch the detail; everything + * `V2_CHAT_DEPLOYMENT_GATED_FIELDS` withholds from the `read`-level list — + * `allowedEmails`, `hasPassword`, `customizations` — is absent for the same + * reason, so this cannot be used to route around the admin-gated detail read. + * The `deploy.chat` capability comes with `list`: a group with the chat + * deployment surface withheld should not still be told what is published. + * + * `isDeployed` is the chat row's own `isActive`, deliberately not + * {@link toEffectiveChatDeploymentView}'s "chat and workflow both live" rule. + * This answers "does this workflow already have a chat to update", which stays + * true while the workflow is undeployed — the editor would otherwise offer to + * launch a chat that already exists. + */ +export const readWorkflowChatDeploymentStatus = defineAuthorizedWorkspaceUseCase({ + operation: chatDeploymentOperations.list, + resolveContext, + authorizationOptions: {}, + async execute({ context }): Promise { + const deployment = context.chatDeployment + if (!deployment) return { isDeployed: false, deployment: null } + return { + isDeployed: deployment.isActive, + deployment: { id: deployment.id, identifier: deployment.identifier }, + } + }, +}) + /** * The permission group's auth-mode allow-list, applied only when the mode * actually changes. diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts index 91726186b88..67ba58b4ace 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { JsonYamlChunker } from './json-yaml-chunker' +import { JsonYamlChunker } from '@/lib/chunkers/json-yaml-chunker' vi.mock('@/lib/tokenization', () => ({ getAccurateTokenCount: (text: string) => Math.ceil(text.length / 4), @@ -37,30 +37,110 @@ describe('JsonYamlChunker', () => { expect(chunks.every((chunk) => chunk.tokenCount <= 100)).toBe(true) }) - describe('isStructuredData', () => { - it('should detect valid JSON', () => { - expect(JsonYamlChunker.isStructuredData('{"key": "value"}')).toBe(true) + describe('chunkStructured', () => { + it('chunks valid JSON', async () => { + await expect(JsonYamlChunker.chunkStructured('{"key": "value"}')).resolves.not.toBeNull() }) - it('should detect valid JSON array', () => { - expect(JsonYamlChunker.isStructuredData('[1, 2, 3]')).toBe(true) + it('chunks a valid JSON array', async () => { + await expect(JsonYamlChunker.chunkStructured('[1, 2, 3]')).resolves.not.toBeNull() }) - it('should detect valid YAML', () => { - expect(JsonYamlChunker.isStructuredData('key: value\nother: data')).toBe(true) + it('chunks valid YAML', async () => { + await expect( + JsonYamlChunker.chunkStructured('key: value\nother: data') + ).resolves.not.toBeNull() }) - it('should return false for plain text parsed as YAML scalar', () => { - expect(JsonYamlChunker.isStructuredData('Hello, this is plain text.')).toBe(false) + it('declines plain text that parses as a YAML scalar', async () => { + await expect( + JsonYamlChunker.chunkStructured('Hello, this is plain text.') + ).resolves.toBeNull() }) - it('should return false for invalid JSON/YAML with unbalanced braces', () => { - expect(JsonYamlChunker.isStructuredData('{invalid: json: content: {{')).toBe(false) + it('declines invalid JSON/YAML with unbalanced braces', async () => { + await expect( + JsonYamlChunker.chunkStructured('{invalid: json: content: {{') + ).resolves.toBeNull() }) - it('should detect nested JSON objects', () => { + it('chunks nested JSON objects', async () => { const nested = JSON.stringify({ level1: { level2: { level3: 'value' } } }) - expect(JsonYamlChunker.isStructuredData(nested)).toBe(true) + await expect(JsonYamlChunker.chunkStructured(nested)).resolves.not.toBeNull() + }) + + it('declines an alias-expansion bomb instead of expanding it', async () => { + const lines = ['a0: &a0 "lol"'] + for (let level = 1; level <= 7; level++) { + lines.push( + `a${level}: &a${level} [${Array(7) + .fill(`*a${level - 1}`) + .join(',')}]` + ) + } + lines.push('top: *a7') + const bomb = lines.join('\n') + + const chunks = await JsonYamlChunker.chunkStructured(bomb, { + chunkSize: 1024, + minCharactersPerChunk: 1, + maxChunks: 5000, + }) + + expect(chunks).toBeNull() + }) + + it('keeps structure for a many-small-node document that fits the budget', async () => { + const flags = JSON.stringify(Array.from({ length: 250_000 }, (_, i) => i % 2 === 0)) + + const chunks = await JsonYamlChunker.chunkStructured(flags, { + chunkSize: 1024, + minCharactersPerChunk: 1, + maxChunks: 1000, + }) + + expect(chunks).not.toBeNull() + expect(chunks?.length).toBeGreaterThan(1) + expect(chunks?.[0].text).toContain('true') + }) + + it('never parses source larger than one output budget', async () => { + const oversized = JSON.stringify({ value: 'x'.repeat(5 * 1024 * 1024) }) + const parse = vi.spyOn(JSON, 'parse') + + try { + await expect( + JsonYamlChunker.chunkStructured(oversized, { + chunkSize: 1024, + minCharactersPerChunk: 1, + maxChunks: 1024, + }) + ).resolves.toBeNull() + expect(parse).not.toHaveBeenCalled() + } finally { + parse.mockRestore() + } + }) + + it('chunks with default options', async () => { + const chunks = await JsonYamlChunker.chunkStructured(JSON.stringify({ test: 'value' })) + + expect(chunks?.length).toBeGreaterThan(0) + }) + + it('honors a custom chunk size', async () => { + const largeObject: Record = {} + for (let i = 0; i < 50; i++) { + largeObject[`key${i}`] = `value${i}`.repeat(20) + } + const json = JSON.stringify(largeObject) + + const chunksSmall = await JsonYamlChunker.chunkStructured(json, { chunkSize: 50 }) + const chunksLarge = await JsonYamlChunker.chunkStructured(json, { chunkSize: 500 }) + + expect(chunksSmall).not.toBeNull() + expect(chunksLarge).not.toBeNull() + expect(chunksSmall?.length).toBeGreaterThan(chunksLarge?.length as number) }) }) @@ -368,28 +448,6 @@ server: }) }) - describe('static chunkJsonYaml method', () => { - it.concurrent('should work with default options', async () => { - const json = JSON.stringify({ test: 'value' }) - const chunks = await JsonYamlChunker.chunkJsonYaml(json) - - expect(chunks.length).toBeGreaterThan(0) - }) - - it.concurrent('should accept custom options', async () => { - const largeObject: Record = {} - for (let i = 0; i < 50; i++) { - largeObject[`key${i}`] = `value${i}`.repeat(20) - } - const json = JSON.stringify(largeObject) - - const chunksSmall = await JsonYamlChunker.chunkJsonYaml(json, { chunkSize: 50 }) - const chunksLarge = await JsonYamlChunker.chunkJsonYaml(json, { chunkSize: 500 }) - - expect(chunksSmall.length).toBeGreaterThan(chunksLarge.length) - }) - }) - describe('chunk metadata', () => { it('preserves every source character and offset when bounding oversized chunks', async () => { const key = 'p'.repeat(80) diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.ts b/apps/sim/lib/chunkers/json-yaml-chunker.ts index 3568132120e..4cbabb144c8 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.ts @@ -9,6 +9,8 @@ import { normalizeTokenChunkSize, tokensToChars, } from '@/lib/chunkers/utils' +import { measureYamlExpansion, type YamlExpansionLimits } from '@/lib/file-parsers/yaml-limits' +import { FILE_PARSER_YAML_LIMITS } from '@/lib/file-parsers/yaml-parser' const logger = createLogger('JsonYamlChunker') @@ -20,40 +22,148 @@ type BoundedChunkMetadataMode = 'text-offsets' | 'preserve-range' const MAX_DEPTH = 5 +/** + * Smallest source ceiling this chunker imposes, so a knowledge base configured + * with tiny chunks keeps structural chunking on documents it indexes perfectly + * well today. + */ +const MIN_SOURCE_BYTES = 4 * 1024 * 1024 + +/** + * How far a document may legitimately expand past its own source. + * + * `measureYamlExpansion` charges a flat per-node allowance, so a compact source + * of small values is charged well above its own length — `[1,1,1]` costs about + * 22 estimated bytes per element against two in source. An order of magnitude of + * headroom therefore covers ordinary document shape, while alias expansion + * overshoots it by several orders. + */ +const MAX_EXPANSION_RATIO = 16 + +/** + * Longest source this chunker will parse: the most text it could ever emit, one + * output budget's worth. A larger document cannot be indexed whole by any + * chunker — `ChunkBudget` stops it either way — so parsing it buys nothing. + */ +function resolveMaxSourceBytes(maxChunks: number | undefined, chunkSize: number): number { + if (maxChunks === undefined) return FILE_PARSER_YAML_LIMITS.maxSerializedBytes + + return Math.min( + FILE_PARSER_YAML_LIMITS.maxSerializedBytes, + Math.max(MIN_SOURCE_BYTES, maxChunks * tokensToChars(chunkSize)) + ) +} + +/** + * What the document is allowed to expand to once it is walked as a tree. + * + * Structural chunking re-serializes what it parsed, so its cost follows the + * document's *expanded* size rather than its source size, and `yaml.load` + * resolves aliases into shared references — a sub-kilobyte source can carry tens + * of megabytes of expansion. `ChunkBudget` cannot bound that: it counts emitted + * chunks, and every parse and serialization happens before the first is emitted. + * + * Two expansions are admissible: one that stays within the output budget, and + * one that stays proportionate to the source. Taking the larger of the two keeps + * transient allocation tied to work the chunker would have done anyway, without + * charging an ordinary large document for the estimator's per-node conservatism. + * Neither is ever allowed past what the file parser itself would hand over. + */ +function resolveExpansionLimits(sourceBytes: number, maxSourceBytes: number): YamlExpansionLimits { + return { + /** Bytes bind here; every reached node charges some, so a self-referential anchor still terminates. */ + maxNodes: Number.MAX_SAFE_INTEGER, + maxSerializedBytes: Math.min( + FILE_PARSER_YAML_LIMITS.maxSerializedBytes, + Math.max(maxSourceBytes, sourceBytes * MAX_EXPANSION_RATIO) + ), + maxDepth: FILE_PARSER_YAML_LIMITS.maxDepth, + } +} + export class JsonYamlChunker { private chunkSize: number private minCharactersPerChunk: number private maxChunks?: number + private readonly maxSourceBytes: number constructor(options: ChunkerOptions = {}) { this.chunkSize = normalizeTokenChunkSize(options.chunkSize ?? 1024, 'JSON/YAML chunk size') this.minCharactersPerChunk = options.minCharactersPerChunk ?? 100 this.maxChunks = options.maxChunks + this.maxSourceBytes = resolveMaxSourceBytes(this.maxChunks, this.chunkSize) } - static isStructuredData(content: string): boolean { + /** + * Read `content` as JSON, falling back to YAML, and measure what the parsed + * value expands to before anything materializes it. + * + * The source-length check comes first so oversized content is never parsed at + * all; the expansion measurement then catches what length alone cannot — alias + * expansion, and the indentation a pretty-printed re-serialization adds. + */ + private parseWithinLimits(content: string): JsonValue | undefined { + if (content.length > this.maxSourceBytes) { + return this.reject( + `source of ${content.length} characters exceeds the ${this.maxSourceBytes}-byte ceiling` + ) + } + + let parsed: unknown try { - const parsed = JSON.parse(content) - return typeof parsed === 'object' && parsed !== null + parsed = JSON.parse(content) } catch { try { - const parsed = yaml.load(content) - return typeof parsed === 'object' && parsed !== null + parsed = yaml.load(content) } catch { - return false + return undefined } } + + if (parsed === undefined) return undefined + + const limits = resolveExpansionLimits(content.length, this.maxSourceBytes) + const measured = measureYamlExpansion(parsed, limits) + if (!measured.within) return this.reject(measured.reason) + + return parsed as JsonValue } - async chunk(content: string): Promise { - try { - let data: JsonValue - try { - data = JSON.parse(content) as JsonValue - } catch { - data = yaml.load(content) as JsonValue + private reject(reason: string): undefined { + logger.warn( + 'Structured content exceeds the chunking expansion limits, declining to expand it', + { + reason, } + ) + return undefined + } + + /** + * Chunk `content` as a structured object or array, or return `null` when it is + * neither — including when its expanded form outgrows the ceiling above. The + * caller then chooses another chunker for it. + */ + static async chunkStructured( + content: string, + options: ChunkerOptions = {} + ): Promise { + const chunker = new JsonYamlChunker(options) + const data = chunker.parseWithinLimits(content) + if (data === null || typeof data !== 'object') return null + + return chunker.chunkParsed(data, content) + } + + async chunk(content: string): Promise { + const data = this.parseWithinLimits(content) + if (data === undefined) return this.chunkAsText(content) + + return this.chunkParsed(data, content) + } + private chunkParsed(data: JsonValue, content: string): Chunk[] { + try { const chunks: Chunk[] = [] this.chunkStructuredData(data, [], 0, chunks, new ChunkBudget(this.maxChunks)) @@ -64,7 +174,7 @@ export class JsonYamlChunker { } catch (error) { if (error instanceof ChunkLimitExceededError) throw error logger.info('Structured data chunking failed, falling back to text chunking') - return this.chunkAsText(content, new ChunkBudget(this.maxChunks)) + return this.chunkAsText(content) } } @@ -299,7 +409,11 @@ export class JsonYamlChunker { } } - private chunkAsText(content: string, budget: ChunkBudget, chunks: Chunk[] = []): Chunk[] { + private chunkAsText( + content: string, + budget: ChunkBudget = new ChunkBudget(this.maxChunks), + chunks: Chunk[] = [] + ): Chunk[] { let currentChunk = '' let currentTokens = 0 let startIndex = 0 @@ -362,9 +476,4 @@ export class JsonYamlChunker { return chunks } - - static async chunkJsonYaml(content: string, options: ChunkerOptions = {}): Promise { - const chunker = new JsonYamlChunker(options) - return chunker.chunk(content) - } } diff --git a/apps/sim/lib/compare/data/competitors/dust.ts b/apps/sim/lib/compare/data/competitors/dust.ts index 5ebf1328fe4..6bd33f4143a 100644 --- a/apps/sim/lib/compare/data/competitors/dust.ts +++ b/apps/sim/lib/compare/data/competitors/dust.ts @@ -15,6 +15,35 @@ export const dustProfile: CompetitorProfile = { }, oneLiner: 'Dust is an enterprise AI agent platform where teams build no-code agents connected to company data and tools in a shared, multiplayer workspace, then deploy them to chat, Slack, and other surfaces.', + leadAnswer: [ + 'Dust is primarily an enterprise AI agent platform built for teams that want no-code agents connected to company data in a shared, multiplayer workspace. Sim is an open-source AI workspace built for teams that want to build, deploy, and manage agents visually, conversationally, or with code. Choose Dust when you want zero visual/flow layer and prefer building purely through forms, text, and templates guided by a conversational assistant. Sim is a stronger fit when you need a visual canvas, self-hosting, real-time multiplayer editing, or environment promotion across dev, QA, and prod.', + ], + betterThanAnswer: [ + 'Sim is the stronger fit when you need explicit control over how an agent is built, hosted, and promoted to production. Dust is the stronger fit when you want that control abstracted away, with agents assembled from forms, instructions, templates, and conversation inside a managed workspace. Choose on that axis: explicit workflow and deployment control, or a builder centered on forms and conversation.', + ], + sectionIntros: { + platform: [ + 'Sim offers a visual canvas, supported self-hosting, live canvas editing, and workspace promotion. Dust provides a hosted, form-based agent builder with shared conversations and Git-based configuration management.', + ], + pricing: [ + 'Sim charges for usage through credits and supports bring-your-own provider keys. Dust combines per-seat subscriptions with monthly AI credit allocations.', + ], + security: [ + 'Sim emphasizes self-hosting and configurable workspace controls, while Dust documents a broader set of managed-service compliance options. Enterprise features and deployment choices affect the exact controls available in each product.', + ], + aiCapabilities: [ + 'Sim provides explicit workflow controls for areas such as evaluation, approvals, iteration, and parallel execution. Dust centers these capabilities on conversational agents that choose among configured tools.', + ], + integrations: [ + 'Sim provides a larger workflow-oriented integration surface and supports custom code, SDKs, MCP publishing, and event triggers. Dust combines managed connections, APIs, triggers, and MCP-based tools around its conversational agent model.', + ], + observability: [ + 'Sim documents block-level traces, retries, alerts, data export, asynchronous runs, and execution limits. Dust documents workspace analytics and background triggers, but several run-level durability controls are not described publicly.', + ], + support: [ + 'Both products provide documentation and learning resources. Sim emphasizes its open-source community and an Enterprise dedicated-support option, while Dust also documents community forums and enterprise onboarding.', + ], + }, standoutFeatures: [ { title: diff --git a/apps/sim/lib/compare/data/competitors/openai-agentkit.ts b/apps/sim/lib/compare/data/competitors/openai-agentkit.ts index d66a26c90de..35519f829ca 100644 --- a/apps/sim/lib/compare/data/competitors/openai-agentkit.ts +++ b/apps/sim/lib/compare/data/competitors/openai-agentkit.ts @@ -31,11 +31,39 @@ export const openaiAgentkitProfile: CompetitorProfile = { }, oneLiner: "OpenAI AgentKit bundled a visual Agent Builder, ChatKit embeddable chat UI, Connector Registry, Guardrails, and Evals for building agentic workflows on OpenAI's models. But OpenAI is winding down Agent Builder and Evals, with full shutdown November 30, 2026, in favor of the code-first Agents SDK or ChatGPT Workspace Agents.", + sectionIntros: { + platform: [ + 'Sim offers a visual builder, supported self-hosting, and promotion across environments. OpenAI Agent Builder is a hosted visual canvas that OpenAI is retiring, leaving the code-first Agents SDK as its successor.', + ], + pricing: [ + { text: 'Sim combines a per-user subscription', href: '/pricing' }, + ' with usage credits and bring-your-own-key exemptions, while OpenAI charges for model tokens and tools without a dedicated AgentKit plan. Estimate workflow volume, token consumption, and paid tool calls when comparing total cost.', + ], + security: [ + 'OpenAI publishes a broader certification portfolio, while Sim provides more direct infrastructure control through ', + { text: 'self-hosting', href: 'https://docs.sim.ai/platform/self-hosting' }, + ' and configurable credential, retention, session, and audit policies.', + ], + aiCapabilities: [ + 'Sim provides visual and natural-language building across ', + { text: 'multiple model providers', href: 'https://docs.sim.ai/introduction' }, + ". Agent Builder's visual selector is limited to OpenAI models and is being retired, although the separate Agents SDK can reach other providers through code-level adapters.", + ], + integrations: [ + 'Sim publishes integration and trigger counts for its first-party automation catalog, while OpenAI combines a smaller documented connector set with access to external MCP servers, which are third-party endpoints OpenAI does not review.', + ], + observability: [ + 'Both products provide run tracing, but their operational models differ. Sim includes visual workflow alerts and error branches, while several OpenAI retry, checkpoint, and recovery capabilities require developers to configure the Agents SDK in code.', + ], + support: [ + 'OpenAI documents 24/7 support and service commitments for qualifying enterprise offerings, while Sim lists dedicated Enterprise support without publishing response-time or uptime terms. Buyers who require contractual guarantees should confirm the exact coverage with each vendor.', + ], + }, standoutFeatures: [ { title: 'An official, open-source Agents SDK wired natively to its own models', description: - "The Agents SDK, openai-agents-python, is open source under the MIT license with over 27,500 GitHub stars and natively wired into OpenAI's own model lineup. It's the path OpenAI is steering AgentKit users toward as Agent Builder and Evals wind down (full shutdown November 30, 2026), and a team fully committed to an all-OpenAI, code-first stack gets that directly.", + "The Agents SDK, openai-agents-python, is open source under the MIT license with over 27,500 GitHub stars and natively wired into OpenAI's own model lineup. It's the path OpenAI is steering AgentKit users toward as Agent Builder and Evals wind down, and a team fully committed to an all-OpenAI, code-first stack gets that directly.", shortDescription: 'Open-source code-first framework, natively wired to OpenAI models.', source: { url: 'https://github.com/openai/openai-agents-python', @@ -120,7 +148,7 @@ export const openaiAgentkitProfile: CompetitorProfile = { value: 'Visual canvas (Agent Builder) for drag-and-drop multi-agent workflow construction, paired with a code-first alternative (Agents SDK, Python/TypeScript)', detail: - 'Agent Builder was a visual, node-based canvas for creating and versioning multi-agent workflows with typed inputs/outputs and live-data preview. It is being deprecated (shutdown November 30, 2026) in favor of the code-first Agents SDK, making the long-term builder paradigm code-based rather than visual.', + 'Agent Builder was a visual, node-based canvas for creating and versioning multi-agent workflows with typed inputs/outputs and live-data preview. It is being deprecated in favor of the code-first Agents SDK, making the long-term builder paradigm code-based rather than visual.', shortValue: 'Visual canvas, deprecated in favor of code-first SDK', confidence: 'verified', sources: [ @@ -213,7 +241,7 @@ export const openaiAgentkitProfile: CompetitorProfile = { value: 'No dev/qa/prod-style environment promotion for full projects. Only single-workflow versioning and code export', detail: - "Agent Builder workflows export as code (Agents SDK, Python or TypeScript) or JSON templates, and templates can sync with a Git repo for reuse, but there's no built-in feature to clone a whole project and promote it between dev/qa/prod environments. Promoting environments means exporting to code and managing them yourself, which lines up with third-party reviews noting Agent Builder lacks production-grade deployment pipelines. Agent Builder is being deprecated, with full shutdown November 30, 2026, in favor of the code-first Agents SDK or ChatGPT Workspace Agents.", + "Agent Builder workflows export as code (Agents SDK, Python or TypeScript) or JSON templates, and templates can sync with a Git repo for reuse, but there's no built-in feature to clone a whole project and promote it between dev/qa/prod environments. Promoting environments means exporting to code and managing them yourself, which lines up with third-party reviews noting Agent Builder lacks production-grade deployment pipelines.", shortValue: 'No built-in dev/qa/prod promotion', confidence: 'estimated', sources: [ @@ -352,9 +380,9 @@ export const openaiAgentkitProfile: CompetitorProfile = { }, customBlocks: { value: - "No: Agent Builder has no feature to publish a workflow as a named, encapsulated block that other org members can drop into their own separate workflows. Its full node palette (Start, Agent, Note, File search, Guardrails, MCP, If/else, While, Human approval, Transform, Set state) has no 'workflow as a block' node, and publishing only creates a versioned snapshot consumable via the API or embedded through ChatKit, not a reusable canvas block for teammates.", + 'No: Agent Builder has no feature to publish a workflow as a named, encapsulated block that other org members can drop into their own separate workflows.', detail: - "Publishing a workflow in Agent Builder produces a versioned object callable via API or embeddable through ChatKit, but that is deploying one workflow as an endpoint, not turning it into a block that appears in other users' canvases with inputs auto-derived from its Start node and internals hidden. Team-level reuse in the documented deployment paths (templates, ChatKit, SDK code export) means copying a template, embedding a chat surface, or exporting code, none of which give other builders a live, encapsulated block that stays in sync with the source workflow's latest published version. Agent Builder itself is also being wound down, with full shutdown November 30, 2026.", + "Its node palette has no 'workflow as a block' node, and the documented reuse paths (templates, ChatKit, SDK code export) give teammates a copy or an endpoint, never a live encapsulated block that stays in sync with the source workflow's latest published version.", shortValue: 'No dedicated publish-as-reusable-block feature found', confidence: 'estimated', sources: [ @@ -451,7 +479,7 @@ export const openaiAgentkitProfile: CompetitorProfile = { value: 'Yes: Evals (datasets, trace grading, automated prompt optimization) and a separate open-source Guardrails layer, but Evals is being deprecated alongside Agent Builder', detail: - 'AgentKit shipped Datasets, Trace grading, and Automated prompt optimization under Evals, plus an open-source modular Guardrails safety layer (PII masking, jailbreak detection). Evals goes read-only October 31, 2026 and is fully shut down November 30, 2026, alongside Agent Builder.', + 'AgentKit shipped Datasets, Trace grading, and Automated prompt optimization under Evals, plus an open-source modular Guardrails safety layer (PII masking, jailbreak detection). Evals is being retired alongside Agent Builder.', shortValue: 'Evals plus Guardrails (Evals sunsetting)', confidence: 'verified', sources: [ @@ -523,7 +551,7 @@ export const openaiAgentkitProfile: CompetitorProfile = { }, agentSkills: { value: - "No: Agent Builder/AgentKit has no dedicated feature for defining a reusable, named prompt or knowledge snippet that multiple agents can share by reference. OpenAI's separate reusable-prompts feature is itself being phased out and is scheduled to shut down November 30, 2026, alongside Agent Builder. A distinct 'Agent Skills' concept exists only in the unrelated Codex product line, not in AgentKit.", + "No: Agent Builder/AgentKit has no dedicated feature for defining a reusable, named prompt or knowledge snippet that multiple agents can share by reference. A distinct 'Agent Skills' concept exists only in the unrelated Codex product line, not in AgentKit.", detail: 'OpenAI recommends migrating reusable prompts to code-managed, versioned helper files instead, which is the opposite direction of a built-in skills feature.', shortValue: 'No dedicated cross-agent skill/snippet feature', @@ -544,8 +572,7 @@ export const openaiAgentkitProfile: CompetitorProfile = { nativeChatDeployment: { value: 'Yes: ChatKit is a native toolkit for embedding a publicly deployable, customizable chat-based agent surface (web widget) backed by a published Agent Builder workflow ID or the Agents SDK, distinct from just a form/API/webhook target.', - detail: - 'ChatKit remains available even as Agent Builder itself is being wound down (shutdown November 30, 2026).', + detail: 'ChatKit remains available even as Agent Builder itself is being wound down.', shortValue: 'Yes, via ChatKit embeddable chat surface', confidence: 'verified', sources: [ diff --git a/apps/sim/lib/compare/data/index.ts b/apps/sim/lib/compare/data/index.ts index 8ad58021459..058e2e17630 100644 --- a/apps/sim/lib/compare/data/index.ts +++ b/apps/sim/lib/compare/data/index.ts @@ -25,4 +25,6 @@ export type { CompetitorProfile, Fact, FactSource, + Prose, + ProseSegment, } from '@/lib/compare/data/types' diff --git a/apps/sim/lib/compare/data/types.ts b/apps/sim/lib/compare/data/types.ts index b974b845ccd..0889269badc 100644 --- a/apps/sim/lib/compare/data/types.ts +++ b/apps/sim/lib/compare/data/types.ts @@ -173,6 +173,17 @@ export interface ComparisonFacts { } } +/** + * One run of comparison prose, optionally hyperlinked. Kept as data (rather + * than markup or a markdown string) so the data layer stays UI-free while + * still expressing the in-sentence citation links that comparison intros + * need. A segment object renders `text` as a link to `href`. + */ +export type ProseSegment = string | { text: string; href: string } + +/** A paragraph of comparison prose, as an ordered run of {@link ProseSegment}s. */ +export type Prose = ProseSegment[] + /** Brand icon + colors for a competitor, sourced from a brand-intelligence lookup rather than the vendor's own docs. */ export interface CompetitorBrand { /** Icon component from @/components/icons rendering this competitor's logo. */ @@ -208,6 +219,24 @@ export interface CompetitorProfile { website: string /** One-sentence, neutral description of what the product is. */ oneLiner: string + /** + * A 2-4 sentence direct answer to "which of these two should I pick", shown + * as the page's lead paragraph ahead of the generic intro. Written so an + * answer engine can quote it standalone: what each product is, then the + * condition under which each one wins. + */ + leadAnswer?: Prose + /** + * Answer to the "Is Sim better than {name}?" section, phrased the way buyers + * ask the question of an AI model. Verdict first, then the condition that + * decides it. 3-5 sentences. + */ + betterThanAnswer?: Prose + /** + * One-sentence lead-in per comparison-table section, stating what that + * section covers so the section is quotable without the rest of the page. + */ + sectionIntros?: Partial> /** * Whether this competitor is, categorically, a visual workflow/automation * builder like Sim. Defaults to `true` when omitted. Set `false` for a diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 64fa3a99f92..597bc4be5f9 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -15,6 +15,7 @@ export interface ToolCatalogEntry { | 'browser_close_tab' | 'browser_drag' | 'browser_extract' + | 'browser_fill_form' | 'browser_find' | 'browser_go_back' | 'browser_go_forward' @@ -150,6 +151,7 @@ export interface ToolCatalogEntry { | 'browser_close_tab' | 'browser_drag' | 'browser_extract' + | 'browser_fill_form' | 'browser_find' | 'browser_go_back' | 'browser_go_forward' @@ -811,6 +813,142 @@ export const BrowserExtract: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserFillForm: ToolCatalogEntry = { + id: 'browser_fill_form', + name: 'browser_fill_form', + route: 'client', + mode: 'async', + parameters: { + additionalProperties: false, + properties: { + fields: { + description: + "Ordered list of 1–8 fields with unique elementId refs from the current top page's latest snapshot. Supply exactly one matching value parameter per kind.", + items: { + oneOf: [ + { + additionalProperties: false, + properties: { elementId: {}, kind: { enum: ['text'] }, text: {} }, + required: ['text'], + }, + { + additionalProperties: false, + properties: { elementId: {}, kind: { enum: ['select'] }, value: {} }, + required: ['value'], + }, + { + additionalProperties: false, + properties: { checked: {}, elementId: {}, kind: { enum: ['checked'] } }, + required: ['checked'], + }, + ], + properties: { + checked: { + description: + 'Desired state for kind=checked. A radio can only be set true; native checkboxes may be true or false.', + type: 'boolean', + }, + elementId: { + description: + "Nonnegative integer element ref from the current page's latest snapshot.", + maximum: 9007199254740991, + minimum: 0, + type: 'integer', + }, + kind: { + description: + 'text requires text; select requires value; checked requires checked. Do not supply parameters for another kind.', + enum: ['text', 'select', 'checked'], + type: 'string', + }, + text: { + description: + 'Replacement content for kind=text, including empty to clear. At most 4096 characters. Ordinary input or textarea only.', + maxLength: 4096, + type: 'string', + }, + value: { + description: + 'Option value or visible label for kind=select. At most 4096 characters. Native single-selection dropdown only.', + maxLength: 4096, + type: 'string', + }, + }, + required: ['elementId', 'kind'], + type: 'object', + }, + maxItems: 8, + minItems: 1, + type: 'array', + }, + }, + required: ['fields'], + type: 'object', + }, + resultSchema: { + type: 'object', + properties: { + completed: { + type: 'boolean', + description: + 'True only when every requested field passed exact verification and the page boundary stayed unchanged.', + }, + completedCount: { + type: 'number', + description: + 'Number of field results whose latest readback matched the requested state. Inspect results for the individual indices.', + }, + doNotRetry: { + type: 'boolean', + description: + 'True when input dispatch began: inspect partial results and a fresh snapshot before deciding on remaining work; never blindly repeat the batch.', + }, + error: { + type: 'string', + description: 'Reason filling stopped; preceding fields may already have taken effect.', + }, + note: { + type: 'string', + description: 'Partial-outcome recovery guidance. Form filling is not atomic.', + }, + notices: { type: 'array', items: { type: 'string' } }, + results: { + type: 'array', + description: + 'Ordered field readbacks. An interrupted write may have no result; absence is not proof that input had no effect.', + items: { + type: 'object', + properties: { + checked: { type: 'boolean' }, + elementId: { type: 'number' }, + index: { type: 'number' }, + kind: { type: 'string', enum: ['text', 'select', 'checked'] }, + redacted: { type: 'boolean' }, + valueLength: { type: 'number' }, + valuePreview: { + type: 'string', + description: + 'Bounded normalized actual field preview, withheld for sensitive autocomplete fields; full-value equality is checked inside the page.', + }, + verified: { + type: 'boolean', + description: + 'Whether the full requested value or checked state matched at the latest successful probe, not merely whether input was dispatched.', + }, + }, + required: ['index', 'elementId', 'kind', 'verified'], + }, + }, + stoppedIndex: { + type: 'number', + description: 'Zero-based field index being processed or verified when filling stopped.', + }, + }, + required: ['completed', 'completedCount', 'results'], + }, + clientExecutable: true, +} + export const BrowserFind: ToolCatalogEntry = { id: 'browser_find', name: 'browser_find', @@ -1395,9 +1533,13 @@ export const BrowserScroll: ToolCatalogEntry = { amount: { type: 'number', description: - 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', + 'Optional distance to scroll in pixels (default: 85% of the viewport height for up/down or width for left/right, so a little context carries over).', + }, + direction: { + type: 'string', + description: 'Scroll direction.', + enum: ['up', 'down', 'left', 'right'], }, - direction: { type: 'string', description: 'Scroll direction.', enum: ['up', 'down'] }, elementId: { type: 'number', description: @@ -1413,14 +1555,29 @@ export const BrowserScroll: ToolCatalogEntry = { type: 'boolean', description: 'Whether the selected region is at its bottom boundary.', }, + atLeft: { + type: 'boolean', + description: + 'Whether the selected region is at its physical left boundary, included for left/right.', + }, + atRight: { + type: 'boolean', + description: + 'Whether the selected region is at its physical right boundary, included for left/right.', + }, atTop: { type: 'boolean', description: 'Whether the selected region is at its top boundary.', }, clientHeight: { type: 'number', description: 'Region viewport height.' }, + clientWidth: { + type: 'number', + description: 'Region viewport width, included for left/right.', + }, movedBy: { type: 'number', - description: 'Actual signed movement; zero means the target did not move.', + description: + 'Actual signed movement on the requested axis: negative for up/left, positive for down/right; zero means the target did not move.', }, notices: { type: 'array', @@ -1429,16 +1586,29 @@ export const BrowserScroll: ToolCatalogEntry = { items: { type: 'string' }, }, scrollHeight: { type: 'number', description: 'Region content height.' }, - scrollTop: { type: 'number', description: 'Resulting region scroll offset.' }, + scrollLeft: { + type: 'number', + description: + 'Resulting horizontal region scroll offset, included for left/right; may be negative in right-to-left regions.', + }, + scrollTop: { type: 'number', description: 'Resulting vertical region scroll offset.' }, + scrollWidth: { + type: 'number', + description: 'Region content width, included for left/right.', + }, target: { type: 'string', description: 'Chosen scroll region label.' }, targetSource: { type: 'string', description: 'element, element-boundary, focus, focus-boundary, viewport-center, viewport-center-boundary, largest-visible, or page.', }, + windowScrollX: { + type: 'number', + description: 'Top-page horizontal window scroll offset after a left/right region scroll.', + }, windowScrollY: { type: 'number', - description: 'Top-page window scroll offset after the region scroll.', + description: 'Top-page vertical window scroll offset after the region scroll.', }, }, required: ['atTop', 'atBottom'], @@ -7338,6 +7508,7 @@ export const TOOL_CATALOG: Record = { [BrowserCloseTab.id]: BrowserCloseTab, [BrowserDrag.id]: BrowserDrag, [BrowserExtract.id]: BrowserExtract, + [BrowserFillForm.id]: BrowserFillForm, [BrowserFind.id]: BrowserFind, [BrowserGoBack.id]: BrowserGoBack, [BrowserGoForward.id]: BrowserGoForward, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index e749e7d4663..a6fa1632823 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -612,6 +612,172 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + browser_fill_form: { + parameters: { + additionalProperties: false, + properties: { + fields: { + description: + "Ordered list of 1–8 fields with unique elementId refs from the current top page's latest snapshot. Supply exactly one matching value parameter per kind.", + items: { + oneOf: [ + { + additionalProperties: false, + properties: { + elementId: {}, + kind: { + enum: ['text'], + }, + text: {}, + }, + required: ['text'], + }, + { + additionalProperties: false, + properties: { + elementId: {}, + kind: { + enum: ['select'], + }, + value: {}, + }, + required: ['value'], + }, + { + additionalProperties: false, + properties: { + checked: {}, + elementId: {}, + kind: { + enum: ['checked'], + }, + }, + required: ['checked'], + }, + ], + properties: { + checked: { + description: + 'Desired state for kind=checked. A radio can only be set true; native checkboxes may be true or false.', + type: 'boolean', + }, + elementId: { + description: + "Nonnegative integer element ref from the current page's latest snapshot.", + maximum: 9007199254740991, + minimum: 0, + type: 'integer', + }, + kind: { + description: + 'text requires text; select requires value; checked requires checked. Do not supply parameters for another kind.', + enum: ['text', 'select', 'checked'], + type: 'string', + }, + text: { + description: + 'Replacement content for kind=text, including empty to clear. At most 4096 characters. Ordinary input or textarea only.', + maxLength: 4096, + type: 'string', + }, + value: { + description: + 'Option value or visible label for kind=select. At most 4096 characters. Native single-selection dropdown only.', + maxLength: 4096, + type: 'string', + }, + }, + required: ['elementId', 'kind'], + type: 'object', + }, + maxItems: 8, + minItems: 1, + type: 'array', + }, + }, + required: ['fields'], + type: 'object', + }, + resultSchema: { + type: 'object', + properties: { + completed: { + type: 'boolean', + description: + 'True only when every requested field passed exact verification and the page boundary stayed unchanged.', + }, + completedCount: { + type: 'number', + description: + 'Number of field results whose latest readback matched the requested state. Inspect results for the individual indices.', + }, + doNotRetry: { + type: 'boolean', + description: + 'True when input dispatch began: inspect partial results and a fresh snapshot before deciding on remaining work; never blindly repeat the batch.', + }, + error: { + type: 'string', + description: 'Reason filling stopped; preceding fields may already have taken effect.', + }, + note: { + type: 'string', + description: 'Partial-outcome recovery guidance. Form filling is not atomic.', + }, + notices: { + type: 'array', + items: { + type: 'string', + }, + }, + results: { + type: 'array', + description: + 'Ordered field readbacks. An interrupted write may have no result; absence is not proof that input had no effect.', + items: { + type: 'object', + properties: { + checked: { + type: 'boolean', + }, + elementId: { + type: 'number', + }, + index: { + type: 'number', + }, + kind: { + type: 'string', + enum: ['text', 'select', 'checked'], + }, + redacted: { + type: 'boolean', + }, + valueLength: { + type: 'number', + }, + valuePreview: { + type: 'string', + description: + 'Bounded normalized actual field preview, withheld for sensitive autocomplete fields; full-value equality is checked inside the page.', + }, + verified: { + type: 'boolean', + description: + 'Whether the full requested value or checked state matched at the latest successful probe, not merely whether input was dispatched.', + }, + }, + required: ['index', 'elementId', 'kind', 'verified'], + }, + }, + stoppedIndex: { + type: 'number', + description: 'Zero-based field index being processed or verified when filling stopped.', + }, + }, + required: ['completed', 'completedCount', 'results'], + }, + }, browser_find: { parameters: { type: 'object', @@ -1277,12 +1443,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { amount: { type: 'number', description: - 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', + 'Optional distance to scroll in pixels (default: 85% of the viewport height for up/down or width for left/right, so a little context carries over).', }, direction: { type: 'string', description: 'Scroll direction.', - enum: ['up', 'down'], + enum: ['up', 'down', 'left', 'right'], }, elementId: { type: 'number', @@ -1299,6 +1465,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'boolean', description: 'Whether the selected region is at its bottom boundary.', }, + atLeft: { + type: 'boolean', + description: + 'Whether the selected region is at its physical left boundary, included for left/right.', + }, + atRight: { + type: 'boolean', + description: + 'Whether the selected region is at its physical right boundary, included for left/right.', + }, atTop: { type: 'boolean', description: 'Whether the selected region is at its top boundary.', @@ -1307,9 +1483,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'number', description: 'Region viewport height.', }, + clientWidth: { + type: 'number', + description: 'Region viewport width, included for left/right.', + }, movedBy: { type: 'number', - description: 'Actual signed movement; zero means the target did not move.', + description: + 'Actual signed movement on the requested axis: negative for up/left, positive for down/right; zero means the target did not move.', }, notices: { type: 'array', @@ -1323,9 +1504,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'number', description: 'Region content height.', }, + scrollLeft: { + type: 'number', + description: + 'Resulting horizontal region scroll offset, included for left/right; may be negative in right-to-left regions.', + }, scrollTop: { type: 'number', - description: 'Resulting region scroll offset.', + description: 'Resulting vertical region scroll offset.', + }, + scrollWidth: { + type: 'number', + description: 'Region content width, included for left/right.', }, target: { type: 'string', @@ -1336,9 +1526,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'element, element-boundary, focus, focus-boundary, viewport-center, viewport-center-boundary, largest-visible, or page.', }, + windowScrollX: { + type: 'number', + description: 'Top-page horizontal window scroll offset after a left/right region scroll.', + }, windowScrollY: { type: 'number', - description: 'Top-page window scroll offset after the region scroll.', + description: 'Top-page vertical window scroll offset after the region scroll.', }, }, required: ['atTop', 'atBottom'], diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index bccc3c01ede..a5cac6da2be 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -326,7 +326,6 @@ export function buildPreviewContentUpdate( previousText.length === 0 || !nextText.startsWith(previousText) || operation === 'patch' || - operation === 'append' || now - lastSnapshotAt >= DELTA_PREVIEW_CHECKPOINT_INTERVAL_MS if (shouldForceSnapshot) { diff --git a/apps/sim/lib/copilot/request/go/file-preview-append-roundtrip.test.ts b/apps/sim/lib/copilot/request/go/file-preview-append-roundtrip.test.ts new file mode 100644 index 00000000000..59a36628be7 --- /dev/null +++ b/apps/sim/lib/copilot/request/go/file-preview-append-roundtrip.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildPreviewContentUpdate } from '@/lib/copilot/request/go/file-preview-adapter' +import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' +import { deriveFilePreviewSession } from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase' + +const CHECKPOINT_MS = 1_000 + +/** + * Producer -> consumer round trip for an `append` preview. + * + * `buildPreviewContentUpdate` decides snapshot vs delta; `deriveFilePreviewSession` is + * the only thing in the app that reads `contentMode`. Emitting deltas instead of a full + * snapshot per chunk is only safe if replaying what the producer emits reconstructs the + * text exactly — this drives the real functions against each other and checks that, + * rather than reasoning about it. + */ +function roundTrip( + chunks: string[], + base: string, + msPerChunk: number +): { rendered: string; expected: string; snapshots: number; deltas: number } { + let lastEmitted = '' + let lastSnapshotAt = 0 + let now = 0 + let streamed = '' + let session: FilePreviewSession | undefined + let version = 0 + let snapshots = 0 + let deltas = 0 + + for (const chunk of chunks) { + streamed += chunk + now += msPerChunk + const nextText = base.length > 0 ? `${base}\n${streamed}` : streamed + const update = buildPreviewContentUpdate(lastEmitted, nextText, lastSnapshotAt, now, 'append') + lastEmitted = nextText + lastSnapshotAt = update.lastSnapshotAt + version += 1 + if (update.contentMode === 'snapshot') snapshots++ + else deltas++ + + session = deriveFilePreviewSession( + session, + { + previewPhase: 'file_preview_content', + content: update.content, + contentMode: update.contentMode, + previewVersion: version, + toolCallId: 'tc_1', + toolName: 'prepare_file_edit', + fileName: 'notes.md', + operation: 'append', + } as never, + 'stream_1', + new Date(now).toISOString() + ) + } + + return { + rendered: session?.previewText ?? '', + expected: base.length > 0 ? `${base}\n${streamed}` : streamed, + snapshots, + deltas, + } +} + +function chunksOf(text: string, size: number): string[] { + const out: string[] = [] + for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size)) + return out +} + +describe('append preview round trip', () => { + it('reconstructs the exact text the user should see, and does it with deltas', () => { + const base = 'Existing file body.\nSecond line.' + const r = roundTrip(chunksOf('The appended paragraph goes here.', 4), base, 20) + + expect(r.rendered).toBe(r.expected) + expect(r.deltas).toBeGreaterThan(0) + }) + + it('holds for realistic token-scale chunking on a large base file', () => { + const base = 'x'.repeat(250 * 1024) + const r = roundTrip(chunksOf('y'.repeat(4096), 10), base, 20) + + expect(r.rendered).toBe(r.expected) + expect(r.rendered.length).toBe(250 * 1024 + 1 + 4096) + }) + + it('still emits a recoverable full snapshot on the checkpoint interval', () => { + // One chunk per 400ms crosses the 1s checkpoint repeatedly. + const r = roundTrip(chunksOf('abcdefghij', 1), 'base', 400) + + expect(r.rendered).toBe(r.expected) + expect(r.snapshots).toBeGreaterThan(1) + expect(r.snapshots * CHECKPOINT_MS).toBeGreaterThan(0) + }) + + it('recovers exactly when the base file changes underneath the stream', () => { + // A divergent base must fall back to a snapshot, not a delta on stale text. + const first = buildPreviewContentUpdate('Old base\nabc', 'New base\nabcd', 100, 200, 'append') + expect(first.contentMode).toBe('snapshot') + expect(first.content).toBe('New base\nabcd') + + const session = deriveFilePreviewSession( + undefined, + { + previewPhase: 'file_preview_content', + content: first.content, + contentMode: first.contentMode, + previewVersion: 1, + toolCallId: 'tc_1', + toolName: 'prepare_file_edit', + fileName: 'notes.md', + operation: 'append', + } as never, + 'stream_1', + new Date().toISOString() + ) + expect(session.previewText).toBe('New base\nabcd') + }) + + it('ignores a replayed event rather than double-appending its delta', () => { + const base = 'Base.' + const chunks = chunksOf('hello world', 3) + let lastEmitted = '' + let lastSnapshotAt = 0 + let now = 0 + let streamed = '' + let session: FilePreviewSession | undefined + let version = 0 + const emitted: Array<{ content: string; contentMode: string; version: number }> = [] + + for (const chunk of chunks) { + streamed += chunk + now += 20 + const u = buildPreviewContentUpdate( + lastEmitted, + `${base}\n${streamed}`, + lastSnapshotAt, + now, + 'append' + ) + lastEmitted = `${base}\n${streamed}` + lastSnapshotAt = u.lastSnapshotAt + version += 1 + emitted.push({ content: u.content, contentMode: u.contentMode, version }) + } + + // Deliver every event twice, out of order for the duplicates. + for (const e of [...emitted, ...emitted]) { + session = deriveFilePreviewSession( + session, + { + previewPhase: 'file_preview_content', + content: e.content, + contentMode: e.contentMode, + previewVersion: e.version, + toolCallId: 'tc_1', + toolName: 'prepare_file_edit', + fileName: 'notes.md', + operation: 'append', + } as never, + 'stream_1', + new Date().toISOString() + ) + } + + expect(session?.previewText).toBe(`${base}\n${streamed}`) + }) +}) diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 7efe9457895..c1e7081cb8c 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -193,14 +193,39 @@ describe('copilot go stream helpers', () => { expect(extractEditContent('{"content":"tab\\tvalue"}')).toBe('tab\tvalue') }) - it('emits full snapshots for append (sidebar viewer uses replace mode; no delta merge)', () => { + /** + * Append extends its own text, so it deltas like `update` does. + * + * It forced a snapshot per emission until the only consumer that could not merge a + * delta was gone — `apply-file-preview-phase.ts` has accumulated them since #4923. + * Because an append preview is `existingContent + streamed`, a snapshot per chunk + * re-sent the whole file on every streamed token, which is `O(file x tokens)` into + * the stream buffer: one 250 KB file cost gigabytes of Redis. + */ + it('emits deltas for append when the preview extends the previous text', () => { expect(buildPreviewContentUpdate('hello', 'hello world', 100, 200, 'append')).toEqual({ - content: 'hello world', + content: ' world', + contentMode: 'delta', + lastSnapshotAt: 100, + }) + }) + + it('still snapshots an append whose base changed underneath it', () => { + expect(buildPreviewContentUpdate('hello', 'HELLO world', 100, 200, 'append')).toEqual({ + content: 'HELLO world', contentMode: 'snapshot', lastSnapshotAt: 200, }) }) + it('still checkpoints an append with a full snapshot on the interval', () => { + expect(buildPreviewContentUpdate('hello', 'hello world', 0, 1_000, 'append')).toEqual({ + content: 'hello world', + contentMode: 'snapshot', + lastSnapshotAt: 1_000, + }) + }) + it('emits deltas for update when the preview extends the previous text', () => { expect(buildPreviewContentUpdate('hello', 'hello world', 100, 200, 'update')).toEqual({ content: ' world', diff --git a/apps/sim/lib/copilot/request/session/writer.test.ts b/apps/sim/lib/copilot/request/session/writer.test.ts index 8ff64276df1..719a22f978c 100644 --- a/apps/sim/lib/copilot/request/session/writer.test.ts +++ b/apps/sim/lib/copilot/request/session/writer.test.ts @@ -186,4 +186,56 @@ describe('StreamWriter', () => { }), ]) }) + + /** + * A delivery failure must not cost the buffer an envelope. + * + * Preview content streams as deltas, so the replay chain is only reconstructible if + * every envelope reaches Redis — including one the client never received. `publish` + * persists after enqueuing and unconditionally, and a failed enqueue marks the + * client disconnected rather than throwing, so the producer is never told a delivery + * failed and never advances past a gap the buffer does not have. + */ + it('persists an envelope whose delivery failed, and stops enqueuing after', async () => { + appendEvents.mockResolvedValue(undefined) + + const writer = new StreamWriter({ + streamId: 'stream-gap', + chatId: 'chat-gap', + requestId: 'req-gap', + }) + + let enqueueCalls = 0 + const controller = { + enqueue: vi.fn(() => { + enqueueCalls += 1 + throw new Error('client gone') + }), + close: vi.fn(), + } as unknown as ReadableStreamDefaultController + + writer.attach(controller) + + expect(() => + writer.publish({ + type: MothershipStreamV1EventType.text, + payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'one' }, + } as StreamEvent) + ).not.toThrow() + + writer.publish({ + type: MothershipStreamV1EventType.text, + payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'two' }, + } as StreamEvent) + + await writer.flush() + + const persisted = appendEvents.mock.calls.flatMap( + ([envelopes]: [Array<{ payload?: { text?: string } }>]) => envelopes + ) + expect(persisted.map((envelope) => envelope.payload?.text)).toEqual(['one', 'two']) + expect(writer.clientDisconnected).toBe(true) + // The failed enqueue disconnects; nothing is pushed at the dead controller again. + expect(enqueueCalls).toBe(1) + }) }) diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts index 44da20fb3ca..26ac1d9200a 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts @@ -85,6 +85,31 @@ describe('executeBrowserToolOnClient', () => { vi.unstubAllGlobals() }) + it('reports stopped form outcomes with partial readbacks and does not replay their writes', async () => { + const toolCallId = nextToolCallId() + const result = { + completed: false, + completedCount: 1, + stoppedIndex: 1, + results: [{ index: 0, elementId: 1, kind: 'text', verified: true, valuePreview: 'filled' }], + doNotRetry: true, + error: 'The next field disappeared', + } + mockExecuteBrowserTool.mockResolvedValue(result) + const params = { fields: [{ elementId: 1, kind: 'text', text: 'filled' }] } + executeBrowserToolOnClient(toolCallId, 'browser_fill_form', params, CHAT_SCOPE) + await flush() + expect(mockReportCompletion).toHaveBeenCalledWith( + toolCallId, + 'error', + 'Form filling stopped; inspect the partial result', + result + ) + executeBrowserToolOnClient(toolCallId, 'browser_fill_form', params, CHAT_SCOPE) + await flush() + expect(mockExecuteBrowserTool).toHaveBeenCalledTimes(1) + }) + it('preserves every executed completion when a guard result arrives at retention capacity', async () => { const replayClaim = vi .spyOn(BrowserToolReplayLedger.prototype, 'claim') diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts index f3f698f304e..c9b52d8b139 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts @@ -74,6 +74,7 @@ const OBSERVATION_ONLY_BROWSER_TOOLS = { browser_click: false, browser_click_at: false, browser_type: false, + browser_fill_form: false, browser_insert_text: false, browser_press_key: false, browser_scroll: false, @@ -87,7 +88,7 @@ const OBSERVATION_ONLY_BROWSER_TOOLS = { const SESSION_CLOSED_MESSAGE = 'The agent browser session is closed, so this browser tool cannot run. ' + - 'Call browser_navigate or browser_open_tab to start a new session, or report the situation to the user. ' + + 'Call browser_open_url, browser_navigate, or browser_open_tab to start a new session, or report the situation to the user. ' + 'Do not retry other browser tools until a new session is open.' /** Tool events older than this are replays, not live instructions — never act on them. */ const MAX_EVENT_AGE_MS = 120_000 @@ -983,10 +984,16 @@ async function doExecuteBrowserTool( } nativeActionPending = false if (cancelled) return + const formStopped = + toolName === 'browser_fill_form' && isRecordLike(result) && result.completed === false reportTerminalCompletion( { - status: ASYNC_TOOL_CONFIRMATION_STATUS.success, - message: 'Browser action completed', + status: formStopped + ? ASYNC_TOOL_CONFIRMATION_STATUS.error + : ASYNC_TOOL_CONFIRMATION_STATUS.success, + message: formStopped + ? 'Form filling stopped; inspect the partial result' + : 'Browser action completed', data: sanitizeResultForModel(toolName, result), }, 'Failed to report successful browser tool completion' diff --git a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.test.ts index bd841d53569..2ee3c23581c 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.test.ts @@ -4,6 +4,7 @@ import JSZip from 'jszip' import { describe, expect, it } from 'vitest' import { extractDocAssets } from '@/lib/copilot/tools/server/files/doc-asset-extract' +import { MAX_OOXML_CENTRAL_DIRECTORY_RECORDS, ZipBombError } from '@/lib/file-parsers/ooxml-limits' const THEME_XML = ` @@ -143,6 +144,21 @@ describe('extractDocAssets', () => { expect(slide.texts.some((t) => t.text.includes('grouped'))).toBe(false) }) + it('refuses an archive the OOXML guard rejects', async () => { + // Every media entry is inflated into a retained Buffer with no cap of its + // own, so the guard is the only thing bounding this. Tripping its + // record-count ceiling asserts the call site is guarded without building a + // multi-megabyte fixture; the size ceilings are covered in zip-guard.test.ts. + const zip = new JSZip() + for (let index = 0; index <= MAX_OOXML_CENTRAL_DIRECTORY_RECORDS; index++) { + zip.file(`ppt/media/image${index}.png`, PNG_BYTES) + } + + await expect( + extractDocAssets(await zip.generateAsync({ type: 'nodebuffer' }), 'pptx') + ).rejects.toThrow(ZipBombError) + }) + it('tolerates a package with no theme or media', async () => { const zip = new JSZip() zip.file('ppt/slides/slide1.xml', '') diff --git a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.ts b/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.ts index fd2c1b6c40f..d3564c30aa9 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.ts @@ -1,4 +1,5 @@ import JSZip from 'jszip' +import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard' /** * Pulls the reusable design material out of an OOXML document (.pptx/.docx): @@ -370,6 +371,11 @@ export async function extractDocAssets( binary: Buffer, format: 'pptx' | 'docx' ): Promise { + // The media loop below inflates every entry into a retained Buffer, so an + // attacker-supplied archive has to be bounded from its central directory first — + // the same guard `extractDocumentStyle` and the document parsers already apply. + assertOoxmlArchiveWithinLimits(binary) + const zip = await JSZip.loadAsync(binary) const prefix = format === 'pptx' ? 'ppt' : 'word' diff --git a/apps/sim/lib/copilot/tools/server/generated-schema.test.ts b/apps/sim/lib/copilot/tools/server/generated-schema.test.ts index 23b906a4cf7..1df1ba9e75a 100644 --- a/apps/sim/lib/copilot/tools/server/generated-schema.test.ts +++ b/apps/sim/lib/copilot/tools/server/generated-schema.test.ts @@ -5,6 +5,45 @@ import { describe, expect, it } from 'vitest' import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema' import { OrchestrationError } from '@/lib/core/orchestration/types' +describe('validateGeneratedToolPayload browser_fill_form parameters', () => { + it('accepts mixed fields, including empty text and false checked state', () => { + const payload = { + fields: [ + { elementId: 0, kind: 'text', text: '' }, + { elementId: 1, kind: 'select', value: 'pro' }, + { elementId: 2, kind: 'checked', checked: false }, + ], + } + expect(validateGeneratedToolPayload('browser_fill_form', 'parameters', payload)).toBe(payload) + }) + + it.each([ + { fields: [] }, + { + fields: Array.from({ length: 9 }, (_, elementId) => ({ elementId, kind: 'text', text: '' })), + }, + { fields: [{ elementId: 1, kind: 'text' }] }, + { fields: [{ elementId: 1, kind: 'select' }] }, + { fields: [{ elementId: 1, kind: 'checked' }] }, + { fields: [{ elementId: 1, kind: 'text', text: 'a', value: 'a' }] }, + { fields: [{ elementId: 1, kind: 'select', value: 'a', checked: false }] }, + { fields: [{ elementId: 1, kind: 'checked', checked: false, text: '' }] }, + { fields: [{ elementId: 1, kind: 'checked', checked: 'false' }] }, + { fields: [{ elementId: 1, kind: 'text', text: null }] }, + { fields: [{ elementId: 1, kind: 'text', text: '', submit: true }] }, + { fields: [{ elementId: -1, kind: 'text', text: '' }] }, + { fields: [{ elementId: 1.5, kind: 'text', text: '' }] }, + { fields: [{ elementId: Number.MAX_SAFE_INTEGER + 1, kind: 'text', text: '' }] }, + { fields: [{ elementId: 1, kind: 'text', text: 'a'.repeat(4097) }] }, + { fields: [{ elementId: 1, kind: 'select', value: 'a'.repeat(4097) }] }, + { fields: [{ elementId: 1, kind: 'text', text: '' }], submit: true }, + ])('rejects malformed form payload %# through the generated contract', (payload) => { + expect(() => validateGeneratedToolPayload('browser_fill_form', 'parameters', payload)).toThrow( + OrchestrationError + ) + }) +}) + /** * The shapes below are what an agent actually sent when the catalog advertised * `updates` as a bare array: the provider-path sanitizer filled the missing diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index f0d18f8a46b..89e15b8c3fd 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -763,6 +763,12 @@ describe('resource-naming titles', () => { }) it('describes semantic browser controls without exposing element ids', () => { + expect( + getToolDisplayTitle('browser_fill_form', { + fields: [{ elementId: 42, kind: 'text', text: 'private form content' }], + }) + ).toBe('Filling form') + expect(getToolCompletedTitle('Filling form')).toBe('Filled form') expect(getToolDisplayTitle('browser_find', { query: 'Submit order' })).toBe( 'Finding "Submit order"' ) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index b0e4a892256..c0a0df3f727 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -635,6 +635,7 @@ const TOOL_TITLES: Record = { browser_drag: 'Dragging element', browser_select_option: 'Selecting option', + browser_fill_form: 'Filling form', browser_set_checked: 'Updating control', browser_hover: 'Hovering element', browser_zoom: 'Changing page zoom', @@ -1332,6 +1333,7 @@ const COMPLETED_VERB_REWRITES: Record = { Extracting: 'Extracted', Fading: 'Faded', Finding: 'Found', + Filling: 'Filled', Gathering: 'Gathered', Generating: 'Generated', Going: 'Went', diff --git a/apps/sim/lib/core/rate-limiter/index.ts b/apps/sim/lib/core/rate-limiter/index.ts index 16761324068..191c7ac5bac 100644 --- a/apps/sim/lib/core/rate-limiter/index.ts +++ b/apps/sim/lib/core/rate-limiter/index.ts @@ -14,6 +14,7 @@ export { enforceIpRateLimit, enforceIpRateLimitWithIndependentBackstop, enforceRecipientRateLimit, + enforceResourceRateLimit, enforceUserOrIpRateLimit, enforceUserRateLimit, } from './route-helpers' diff --git a/apps/sim/lib/core/rate-limiter/route-helpers.test.ts b/apps/sim/lib/core/rate-limiter/route-helpers.test.ts index 42786dc3210..ce4c9a7ba10 100644 --- a/apps/sim/lib/core/rate-limiter/route-helpers.test.ts +++ b/apps/sim/lib/core/rate-limiter/route-helpers.test.ts @@ -25,6 +25,7 @@ vi.mock('@/lib/core/rate-limiter/storage', async () => { import { enforceIpRateLimit, enforceIpRateLimitWithIndependentBackstop, + enforceResourceRateLimit, enforceUserOrIpRateLimit, enforceUserRateLimit, } from './route-helpers' @@ -36,6 +37,80 @@ describe('route-helpers rate limiting', () => { vi.clearAllMocks() }) + describe('enforceIpRateLimitWithIndependentBackstop', () => { + it('scopes the per-IP bucket to a resource without polluting the bucket name', async () => { + consume.mockResolvedValueOnce({ + allowed: true, + tokensRemaining: 19, + resetAt: new Date(Date.now() + 60_000), + }) + + requestUtilsMockFns.mockGetClientIp.mockReturnValue('203.0.113.9') + + const result = await enforceIpRateLimitWithIndependentBackstop( + 'chat-execute', + createMockRequest('POST'), + { maxTokens: 40, refillRate: 20, refillIntervalMs: 60_000 }, + 'chat-1' + ) + + expect(result).toBeNull() + expect(consume).toHaveBeenCalledWith( + 'route:chat-execute:resource:chat-1:ip:203.0.113.9', + 1, + expect.anything() + ) + }) + + it('keeps the unscoped key shape when no resource is named', async () => { + consume.mockResolvedValueOnce({ + allowed: true, + tokensRemaining: 9, + resetAt: new Date(Date.now() + 60_000), + }) + + requestUtilsMockFns.mockGetClientIp.mockReturnValue('203.0.113.9') + + await enforceIpRateLimitWithIndependentBackstop('forget-password', createMockRequest('POST')) + + expect(consume).toHaveBeenCalledWith( + 'route:forget-password:ip:203.0.113.9', + 1, + expect.anything() + ) + }) + }) + + describe('enforceResourceRateLimit', () => { + const config = { maxTokens: 300, refillRate: 300, refillIntervalMs: 60_000 } + + it('keys the bucket on the resource, not on the caller', async () => { + consume.mockResolvedValueOnce({ + allowed: true, + tokensRemaining: 299, + resetAt: new Date(Date.now() + 60_000), + }) + + const result = await enforceResourceRateLimit('chat-execute', 'chat-1', config) + + expect(result).toBeNull() + expect(consume).toHaveBeenCalledWith('route:chat-execute:resource:chat-1', 1, config) + }) + + it('returns a 429 with Retry-After when the resource budget is spent', async () => { + consume.mockResolvedValueOnce({ + allowed: false, + tokensRemaining: 0, + resetAt: new Date(Date.now() + 30_000), + }) + + const result = await enforceResourceRateLimit('chat-execute', 'chat-1', config) + + expect(result?.status).toBe(429) + expect(Number(result?.headers.get('Retry-After'))).toBeGreaterThan(0) + }) + }) + describe('enforceUserRateLimit', () => { it('returns null when the bucket has tokens left', async () => { consume.mockResolvedValueOnce({ diff --git a/apps/sim/lib/core/rate-limiter/route-helpers.ts b/apps/sim/lib/core/rate-limiter/route-helpers.ts index c826f18853e..2bed1f0a3ef 100644 --- a/apps/sim/lib/core/rate-limiter/route-helpers.ts +++ b/apps/sim/lib/core/rate-limiter/route-helpers.ts @@ -60,22 +60,25 @@ async function enforceIpRateLimitWithPolicy( bucketName: string, request: NextRequest, config: TokenBucketConfig, - unresolvedClientPolicy: 'deny' | 'defer' + unresolvedClientPolicy: 'deny' | 'defer', + resourceId?: string ): Promise { const ip = getClientIp(request) if (!ip) { logger.warn('Unable to resolve client IP for public rate limit', { bucket: bucketName, + resourceId, unresolvedClientPolicy, }) return unresolvedClientPolicy === 'deny' ? buildRateLimitResponse(new Date(Date.now() + config.refillIntervalMs)) : null } - const key = `route:${bucketName}:ip:${ip}` + const scope = resourceId ? `resource:${resourceId}:` : '' + const key = `route:${bucketName}:${scope}ip:${ip}` const { allowed, resetAt } = await rateLimiter.checkRateLimitDirect(key, config) if (allowed) return null - logger.warn('IP rate limit exceeded', { bucket: bucketName, ip }) + logger.warn('IP rate limit exceeded', { bucket: bucketName, resourceId, ip }) return buildRateLimitResponse(resetAt) } @@ -91,13 +94,19 @@ export async function enforceIpRateLimit( /** * Apply a per-IP bucket when resolvable, deferring unresolved clients to an * independent non-IP limit that the caller must enforce before any side effect. + * + * Pass `resourceId` to give each resource its own per-IP budget — the caller + * that pairs this with {@link enforceResourceRateLimit} wants both scoped the + * same way. It belongs here rather than interpolated into `bucketName`, which + * is emitted as a log field and has to stay low-cardinality. */ export async function enforceIpRateLimitWithIndependentBackstop( bucketName: string, request: NextRequest, - config: TokenBucketConfig = DEFAULT_PUBLIC_IP_ROUTE_LIMIT + config: TokenBucketConfig = DEFAULT_PUBLIC_IP_ROUTE_LIMIT, + resourceId?: string ): Promise { - return enforceIpRateLimitWithPolicy(bucketName, request, config, 'defer') + return enforceIpRateLimitWithPolicy(bucketName, request, config, 'defer', resourceId) } /** @@ -121,6 +130,33 @@ export async function enforceRecipientRateLimit( return buildRateLimitResponse(resetAt) } +/** + * Apply a token bucket to one resource, independently of who is calling. + * + * The backstop for a cost borne by a resource's owner rather than by its + * caller: a deployed chat runs its owner's workflow on their plan bucket, + * credits and concurrency reservation for anyone holding the link, so a per-IP + * limit alone leaves the owner exposed to attempts spread across addresses and + * to callers whose proxy chain resolves to no IP at all. Pair it with + * {@link enforceIpRateLimitWithIndependentBackstop}, which is the "deferring + * unresolved clients to an independent non-IP limit" half of the same shape. + * + * Consult the per-IP bucket first and return on its refusal: debiting both + * unconditionally would let one flooding IP drain the resource's budget at full + * speed and 429 the legitimate audience with it. + */ +export async function enforceResourceRateLimit( + bucketName: string, + resourceId: string, + config: TokenBucketConfig +): Promise { + const key = `route:${bucketName}:resource:${resourceId}` + const { allowed, resetAt } = await rateLimiter.checkRateLimitDirect(key, config) + if (allowed) return null + logger.warn('Resource rate limit exceeded', { bucket: bucketName, resourceId }) + return buildRateLimitResponse(resetAt) +} + /** * Apply a per-workspace token bucket. Use for routes whose cost is borne by the * workspace rather than the acting user — a shared budget any member spends diff --git a/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts b/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts index 13b3761f8a9..63809f28004 100644 --- a/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts +++ b/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts @@ -159,6 +159,30 @@ describe('completeQuickBooksConnection', () => { }) }) + it('never persists the Intuit identity token', async () => { + queueTableRows(account, []) + mocks.exchangeAuthorizationCode.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + idToken: 'intuit-oidc-identity-jwt', + accessTokenExpiresIn: 3600, + refreshTokenExpiresIn: 8_726_400, + scope: '', + }) + + await completeQuickBooksConnection.execute({ + principal, + input: { + draftId: 'draft-1', + code: 'authorization-code', + realmId: '1234567890', + redirectUri: 'https://sim.test/api/auth/oauth2/callback/quickbooks', + }, + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ idToken: null })) + }) + it('fails before token exchange when the draft does not carry encrypted app credentials', async () => { mocks.getActiveDraft.mockResolvedValueOnce({ id: 'draft-1', diff --git a/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts b/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts index f433b85aeb7..501bfd578e9 100644 --- a/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts +++ b/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts @@ -98,7 +98,13 @@ export const completeQuickBooksConnection = defineAuthorizedWorkspaceUseCase({ const accountValues = { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, - idToken: tokens.idToken ?? null, + /** + * Intuit's OIDC identity JWT is only meaningful at connection time, where + * `profile.accountId` is already derived from it. Persisting it would project + * the token into the credential payload of every QuickBooks tool call, none of + * which read it. + */ + idToken: null, accessTokenExpiresAt, refreshTokenExpiresAt, scope: tokens.scope || getCanonicalScopesForProvider('quickbooks').join(' '), diff --git a/apps/sim/lib/file-parsers/yaml-parser.ts b/apps/sim/lib/file-parsers/yaml-parser.ts index c8ed21517cd..8823cc4f6d8 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.ts @@ -10,7 +10,7 @@ import { measureYamlExpansion, type YamlExpansionLimits } from '@/lib/file-parse * the byte cap bounds output a sub-1 KB input can inflate to hundreds of MB; * the depth cap bounds the traversal's own working set. */ -const FILE_PARSER_YAML_LIMITS: YamlExpansionLimits = { +export const FILE_PARSER_YAML_LIMITS: YamlExpansionLimits = { maxNodes: 5_000_000, maxSerializedBytes: 64 * 1024 * 1024, maxDepth: 500, diff --git a/apps/sim/lib/internal/quickbooks/contract-param-parity.test.ts b/apps/sim/lib/internal/quickbooks/contract-param-parity.test.ts new file mode 100644 index 00000000000..74f31d70952 --- /dev/null +++ b/apps/sim/lib/internal/quickbooks/contract-param-parity.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + quickBooksAddAttachmentContract, + quickBooksCreateBillPaymentContract, + quickBooksDownloadDocumentContract, + quickBooksUpdateBillContract, + quickBooksUpdateBillPaymentContract, + quickBooksUpdateCreditMemoContract, + quickBooksUpdateCustomerPaymentContract, + quickBooksUpdateEmployeeContract, + quickBooksUpdateItemContract, + quickBooksUpdatePurchaseContract, + quickBooksUpdatePurchaseOrderContract, + quickBooksUpdateRefundReceiptContract, + quickBooksUpdateVendorContract, + quickBooksUpdateVendorCreditContract, +} from '@/lib/api/contracts/tools/quickbooks' +import { quickbooksAddAttachmentTool } from '@/tools/quickbooks/add_attachment' +import { quickbooksCreateBillPaymentTool } from '@/tools/quickbooks/create_bill_payment' +import { quickbooksDownloadAttachmentTool } from '@/tools/quickbooks/download_attachment' +import { quickbooksDownloadTransactionPdfTool } from '@/tools/quickbooks/download_transaction_pdf' +import { quickbooksUpdateBillTool } from '@/tools/quickbooks/update_bill' +import { quickbooksUpdateBillPaymentTool } from '@/tools/quickbooks/update_bill_payment' +import { quickbooksUpdateCreditMemoTool } from '@/tools/quickbooks/update_credit_memo' +import { quickbooksUpdateCustomerPaymentTool } from '@/tools/quickbooks/update_customer_payment' +import { quickbooksUpdateEmployeeTool } from '@/tools/quickbooks/update_employee' +import { quickbooksUpdateItemTool } from '@/tools/quickbooks/update_item' +import { quickbooksUpdatePurchaseTool } from '@/tools/quickbooks/update_purchase' +import { quickbooksUpdatePurchaseOrderTool } from '@/tools/quickbooks/update_purchase_order' +import { quickbooksUpdateRefundReceiptTool } from '@/tools/quickbooks/update_refund_receipt' +import { quickbooksUpdateVendorTool } from '@/tools/quickbooks/update_vendor' +import { quickbooksUpdateVendorCreditTool } from '@/tools/quickbooks/update_vendor_credit' + +/** + * A contract body is a Zod object, so any key it does not declare is STRIPPED + * before the provider operation runs — silently, with no validation error. A + * tool param that the contract omits is therefore dead: the user fills it in, + * the block forwards it, and it never reaches Intuit. + */ +const CONTRACT_BOUND_OPERATIONS = [ + ['create_bill_payment', quickbooksCreateBillPaymentTool, quickBooksCreateBillPaymentContract], + ['update_bill', quickbooksUpdateBillTool, quickBooksUpdateBillContract], + ['update_bill_payment', quickbooksUpdateBillPaymentTool, quickBooksUpdateBillPaymentContract], + ['update_credit_memo', quickbooksUpdateCreditMemoTool, quickBooksUpdateCreditMemoContract], + [ + 'update_customer_payment', + quickbooksUpdateCustomerPaymentTool, + quickBooksUpdateCustomerPaymentContract, + ], + ['update_employee', quickbooksUpdateEmployeeTool, quickBooksUpdateEmployeeContract], + ['update_item', quickbooksUpdateItemTool, quickBooksUpdateItemContract], + ['update_purchase', quickbooksUpdatePurchaseTool, quickBooksUpdatePurchaseContract], + [ + 'update_purchase_order', + quickbooksUpdatePurchaseOrderTool, + quickBooksUpdatePurchaseOrderContract, + ], + [ + 'update_refund_receipt', + quickbooksUpdateRefundReceiptTool, + quickBooksUpdateRefundReceiptContract, + ], + ['update_vendor', quickbooksUpdateVendorTool, quickBooksUpdateVendorContract], + ['update_vendor_credit', quickbooksUpdateVendorCreditTool, quickBooksUpdateVendorCreditContract], +] as const + +/** + * The file operations do not expose a flat `shape`: the download body is a + * discriminated union (one option per `documentKind`) and the add-attachment + * body carries a `superRefine`. Their declared keys are still introspectable, + * so they are held to the same parity rule as the JSON operations. + */ +const FILE_OPERATIONS = [ + [ + 'download_attachment', + quickbooksDownloadAttachmentTool, + unionOptionKeys(quickBooksDownloadDocumentContract.body, 'attachment'), + ], + [ + 'download_transaction_pdf', + quickbooksDownloadTransactionPdfTool, + unionOptionKeys(quickBooksDownloadDocumentContract.body, 'transaction_pdf'), + ], + [ + 'add_attachment', + quickbooksAddAttachmentTool, + new Set( + Object.keys( + (quickBooksAddAttachmentContract.body as unknown as { shape: Record }) + .shape + ) + ), + ], +] as const + +/** Keys declared by the union option whose `documentKind` literal matches. */ +function unionOptionKeys(body: unknown, documentKind: string): Set { + const options = (body as { options: Array<{ shape: Record }> }) + .options + const option = options.find((candidate) => candidate.shape.documentKind?.value === documentKind) + if (!option) throw new Error(`No download contract option for documentKind ${documentKind}`) + return new Set(Object.keys(option.shape)) +} + +describe('QuickBooks contract/tool param parity', () => { + it.each(CONTRACT_BOUND_OPERATIONS)( + '%s declares every tool param in its contract body', + (_name, tool, contract) => { + const bodyShape = (contract.body as unknown as { shape: Record }).shape + const declared = new Set(Object.keys(bodyShape)) + const dropped = Object.keys(tool.params).filter((param) => !declared.has(param)) + expect(dropped).toEqual([]) + } + ) + + it.each(FILE_OPERATIONS)( + '%s declares every tool param in its contract body', + (_n, tool, declared) => { + const dropped = Object.keys(tool.params).filter((param) => !declared.has(param)) + expect(dropped).toEqual([]) + } + ) +}) diff --git a/apps/sim/lib/internal/quickbooks/execute-tool.test.ts b/apps/sim/lib/internal/quickbooks/execute-tool.test.ts index 5cdd77e98e6..a0ccb2bae3a 100644 --- a/apps/sim/lib/internal/quickbooks/execute-tool.test.ts +++ b/apps/sim/lib/internal/quickbooks/execute-tool.test.ts @@ -73,6 +73,94 @@ function request(overrides: Partial = {}): InternalTo } } +const AUTH_INPUT = { + accessToken: 'token', + realmId: '123', + quickBooksEnvironment: 'sandbox', +} as const + +const PROVIDER_OPERATIONS: ReadonlyArray< + [string, ReturnType, Record, Record] +> = [ + [ + 'quickbooks_create_bill_payment', + mocks.createBillPayment, + { + vendorId: 'vendor-1', + totalAmount: 25, + paymentType: 'check', + paymentAccountId: 'account-1', + }, + { totalAmount: '25' }, + ], + [ + 'quickbooks_update_bill', + mocks.updateBill, + { billId: 'bill-1', syncToken: '3' }, + { billId: '' }, + ], + [ + 'quickbooks_update_bill_payment', + mocks.updateBillPayment, + { billPaymentId: 'bill-payment-1', syncToken: '3' }, + { billPaymentId: '' }, + ], + [ + 'quickbooks_update_credit_memo', + mocks.updateCreditMemo, + { transactionId: 'credit-memo-1', syncToken: '3' }, + { transactionId: '' }, + ], + [ + 'quickbooks_update_customer_payment', + mocks.updateCustomerPayment, + { paymentId: 'payment-1', syncToken: '3' }, + { paymentId: '' }, + ], + [ + 'quickbooks_update_employee', + mocks.updateEmployee, + { employeeId: 'employee-1', syncToken: '3' }, + { employeeId: '' }, + ], + [ + 'quickbooks_update_item', + mocks.updateItem, + { itemId: 'item-1', syncToken: '3' }, + { unitPrice: 'free' }, + ], + [ + 'quickbooks_update_purchase', + mocks.updatePurchase, + { purchaseId: 'purchase-1', syncToken: '3' }, + { purchaseId: '' }, + ], + [ + 'quickbooks_update_purchase_order', + mocks.updatePurchaseOrder, + { purchaseOrderId: 'purchase-order-1', syncToken: '3' }, + { purchaseOrderId: '' }, + ], + [ + 'quickbooks_update_refund_receipt', + mocks.updateRefundReceipt, + { transactionId: 'refund-receipt-1', syncToken: '3' }, + { transactionId: '' }, + ], + [ + 'quickbooks_update_vendor', + mocks.updateVendor, + { vendorId: 'vendor-1', syncToken: '3' }, + { syncToken: '' }, + ], + [ + 'quickbooks_update_vendor_credit', + mocks.updateVendorCredit, + { vendorCreditId: 'vendor-credit-1', syncToken: '3' }, + { vendorCreditId: '' }, + ], +] + describe('executeQuickBooksTool', () => { beforeEach(() => { vi.clearAllMocks() @@ -99,46 +187,87 @@ describe('executeQuickBooksTool', () => { } }) - it.each([ - ['quickbooks_create_bill_payment', mocks.createBillPayment], - ['quickbooks_update_bill', mocks.updateBill], - ['quickbooks_update_bill_payment', mocks.updateBillPayment], - ['quickbooks_update_credit_memo', mocks.updateCreditMemo], - ['quickbooks_update_customer_payment', mocks.updateCustomerPayment], - ['quickbooks_update_employee', mocks.updateEmployee], - ['quickbooks_update_item', mocks.updateItem], - ['quickbooks_update_purchase', mocks.updatePurchase], - ['quickbooks_update_purchase_order', mocks.updatePurchaseOrder], - ['quickbooks_update_refund_receipt', mocks.updateRefundReceipt], - ['quickbooks_update_vendor', mocks.updateVendor], - ['quickbooks_update_vendor_credit', mocks.updateVendorCredit], - ])('dispatches %s through its internal provider operation', async (toolId, operation) => { - const controller = new AbortController() - const operationRequest = request({ - toolId, - input: { - accessToken: 'token', - realmId: '123', - quickBooksEnvironment: 'sandbox', - entityId: 'entity-1', - }, - signal: controller.signal, - }) + it.each(PROVIDER_OPERATIONS)( + 'dispatches %s through its internal provider operation', + async (toolId, operation, operationInput) => { + const controller = new AbortController() + const operationRequest = request({ + toolId, + input: { ...AUTH_INPUT, ...operationInput }, + signal: controller.signal, + }) + + const response = await executeQuickBooksTool(operationRequest) - const response = await executeQuickBooksTool(operationRequest) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + output: { id: 'entity-1' }, + }) + expect(operation).toHaveBeenCalledWith( + { ...AUTH_INPUT, ...operationInput }, + controller.signal + ) + } + ) + + it.each(PROVIDER_OPERATIONS)( + 'rejects %s input the contract refuses', + async (toolId, operation, operationInput, invalidOverride) => { + const response = await executeQuickBooksTool( + request({ toolId, input: { ...AUTH_INPUT, ...operationInput, ...invalidOverride } }) + ) + + expect(response.status).toBe(400) + expect(operation).not.toHaveBeenCalled() + } + ) + + it('drops keys no provider operation contract declares', async () => { + const response = await executeQuickBooksTool( + request({ + toolId: 'quickbooks_update_vendor', + input: { ...AUTH_INPUT, vendorId: 'vendor-1', syncToken: '3', credential: 'credential-1' }, + }) + ) expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - success: true, - output: { id: 'entity-1' }, - }) - expect(operation).toHaveBeenCalledWith( - operationRequest.input, - controller.signal, - operationRequest.context + expect(mocks.updateVendor).toHaveBeenCalledWith( + { ...AUTH_INPUT, vendorId: 'vendor-1', syncToken: '3' }, + undefined ) }) + it('rejects provider operations without trusted user identity', async () => { + const response = await executeQuickBooksTool( + request({ + toolId: 'quickbooks_update_vendor', + input: { ...AUTH_INPUT, vendorId: 'vendor-1', syncToken: '3' }, + context: { workflowId: 'workflow-1' }, + }) + ) + + expect(response.status).toBe(401) + expect(mocks.updateVendor).not.toHaveBeenCalled() + }) + + it('rejects oversized provider operation input before dispatch', async () => { + const response = await executeQuickBooksTool( + request({ + toolId: 'quickbooks_update_vendor', + input: { + ...AUTH_INPUT, + vendorId: 'vendor-1', + syncToken: '3', + extra: 'x'.repeat(1024 * 1024 + 1), + }, + }) + ) + + expect(response.status).toBe(413) + expect(mocks.updateVendor).not.toHaveBeenCalled() + }) + it('dispatches downloads with trusted execution context', async () => { const controller = new AbortController() diff --git a/apps/sim/lib/internal/quickbooks/execute-tool.ts b/apps/sim/lib/internal/quickbooks/execute-tool.ts index da73d506e80..b4e299adc10 100644 --- a/apps/sim/lib/internal/quickbooks/execute-tool.ts +++ b/apps/sim/lib/internal/quickbooks/execute-tool.ts @@ -1,10 +1,21 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { - quickBooksAddAttachmentBodySchema, - quickBooksDownloadDocumentBodySchema, + quickBooksAddAttachmentContract, + quickBooksCreateBillPaymentContract, + quickBooksDownloadDocumentContract, + quickBooksUpdateBillContract, + quickBooksUpdateBillPaymentContract, + quickBooksUpdateCreditMemoContract, + quickBooksUpdateCustomerPaymentContract, + quickBooksUpdateEmployeeContract, + quickBooksUpdateItemContract, + quickBooksUpdatePurchaseContract, + quickBooksUpdatePurchaseOrderContract, + quickBooksUpdateRefundReceiptContract, + quickBooksUpdateVendorContract, + quickBooksUpdateVendorCreditContract, } from '@/lib/api/contracts/tools/quickbooks' -import { getValidationErrorMessage } from '@/lib/api/server' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { executeQuickBooksAddAttachment, @@ -26,7 +37,8 @@ import { executeQuickBooksUpdateVendorCreditOperation, executeQuickBooksUpdateVendorOperation, } from '@/lib/internal/quickbooks/provider-operations' -import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import { executeInternalJsonToolOperation } from '@/lib/internal/tool-operations/execute-json-operation' +import { parseInternalContractInput } from '@/lib/internal/tool-operations/parse-contract-input' import type { InternalToolOperationCall, InternalToolOperationHandler, @@ -76,50 +88,118 @@ function operationContext(request: InternalToolOperationCall): QuickBooksOperati } } +/** + * Every QuickBooks tool id passes the same admission gates — cancellation, the + * operation input cap, and the trusted execution identity — before any provider + * work is dispatched. + */ export const executeQuickBooksTool: InternalToolOperationHandler = async (request) => { request.signal?.throwIfAborted() + + const sizeError = inputSizeError(request.input) + if (sizeError) return sizeError + + const context = operationContext(request) + if (!context) { + return Response.json({ success: false, error: 'Authentication required' }, { status: 401 }) + } + switch (request.toolId) { case 'quickbooks_create_bill_payment': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksCreateBillPaymentContract, + request.input, executeQuickBooksCreateBillPaymentOperation, - request + 'Failed to create QuickBooks bill payment', + request.signal ) case 'quickbooks_update_bill': - return executeToolOperationImplementation(executeQuickBooksUpdateBillOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateBillContract, + request.input, + executeQuickBooksUpdateBillOperation, + 'Failed to update QuickBooks bill', + request.signal + ) case 'quickbooks_update_bill_payment': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdateBillPaymentContract, + request.input, executeQuickBooksUpdateBillPaymentOperation, - request + 'Failed to update QuickBooks bill payment', + request.signal ) case 'quickbooks_update_credit_memo': - return executeToolOperationImplementation(executeQuickBooksUpdateCreditMemoOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateCreditMemoContract, + request.input, + executeQuickBooksUpdateCreditMemoOperation, + 'Failed to update QuickBooks credit memo', + request.signal + ) case 'quickbooks_update_customer_payment': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdateCustomerPaymentContract, + request.input, executeQuickBooksUpdateCustomerPaymentOperation, - request + 'Failed to update QuickBooks customer payment', + request.signal ) case 'quickbooks_update_employee': - return executeToolOperationImplementation(executeQuickBooksUpdateEmployeeOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateEmployeeContract, + request.input, + executeQuickBooksUpdateEmployeeOperation, + 'Failed to update QuickBooks employee', + request.signal + ) case 'quickbooks_update_item': - return executeToolOperationImplementation(executeQuickBooksUpdateItemOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateItemContract, + request.input, + executeQuickBooksUpdateItemOperation, + 'Failed to update QuickBooks item', + request.signal + ) case 'quickbooks_update_purchase': - return executeToolOperationImplementation(executeQuickBooksUpdatePurchaseOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdatePurchaseContract, + request.input, + executeQuickBooksUpdatePurchaseOperation, + 'Failed to update QuickBooks purchase', + request.signal + ) case 'quickbooks_update_purchase_order': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdatePurchaseOrderContract, + request.input, executeQuickBooksUpdatePurchaseOrderOperation, - request + 'Failed to update QuickBooks purchase order', + request.signal ) case 'quickbooks_update_refund_receipt': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdateRefundReceiptContract, + request.input, executeQuickBooksUpdateRefundReceiptOperation, - request + 'Failed to update QuickBooks refund receipt', + request.signal ) case 'quickbooks_update_vendor': - return executeToolOperationImplementation(executeQuickBooksUpdateVendorOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateVendorContract, + request.input, + executeQuickBooksUpdateVendorOperation, + 'Failed to update QuickBooks vendor', + request.signal + ) case 'quickbooks_update_vendor_credit': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdateVendorCreditContract, + request.input, executeQuickBooksUpdateVendorCreditOperation, - request + 'Failed to update QuickBooks vendor credit', + request.signal ) } @@ -133,28 +213,13 @@ export const executeQuickBooksTool: InternalToolOperationHandler = async (reques ) } - const sizeError = inputSizeError(request.input) - if (sizeError) return sizeError - const context = operationContext(request) - if (!context) { - return Response.json({ success: false, error: 'Authentication required' }, { status: 401 }) - } - try { if (request.toolId === 'quickbooks_add_attachment') { - const parsed = quickBooksAddAttachmentBodySchema.safeParse(request.input) - if (!parsed.success) { - return Response.json( - { - success: false, - error: getValidationErrorMessage(parsed.error, 'Invalid request data'), - }, - { status: 400 } - ) - } + const parsed = parseInternalContractInput(quickBooksAddAttachmentContract, request.input) + if (!parsed.success) return parsed.response return Response.json({ success: true, - output: await executeQuickBooksAddAttachment(parsed.data, context), + output: await executeQuickBooksAddAttachment(parsed.data.body, context), }) } @@ -163,19 +228,11 @@ export const executeQuickBooksTool: InternalToolOperationHandler = async (reques documentKind: request.toolId === 'quickbooks_download_attachment' ? 'attachment' : 'transaction_pdf', } - const parsed = quickBooksDownloadDocumentBodySchema.safeParse(documentInput) - if (!parsed.success) { - return Response.json( - { - success: false, - error: getValidationErrorMessage(parsed.error, 'Invalid request data'), - }, - { status: 400 } - ) - } + const parsed = parseInternalContractInput(quickBooksDownloadDocumentContract, documentInput) + if (!parsed.success) return parsed.response return Response.json({ success: true, - output: await executeQuickBooksDownloadDocument(parsed.data, context), + output: await executeQuickBooksDownloadDocument(parsed.data.body, context), }) } catch (error) { request.signal?.throwIfAborted() diff --git a/apps/sim/lib/internal/quickbooks/operations.test.ts b/apps/sim/lib/internal/quickbooks/operations.test.ts index a7784674c94..27b9044ea6b 100644 --- a/apps/sim/lib/internal/quickbooks/operations.test.ts +++ b/apps/sim/lib/internal/quickbooks/operations.test.ts @@ -171,6 +171,34 @@ describe('QuickBooks internal operations', () => { expect(mocks.uploadCopilotFile).not.toHaveBeenCalled() }) + it('refuses a transaction PDF that advertises more than the attachment limit', async () => { + const pdf = new TextEncoder().encode('%PDF-1.7\n') + vi.mocked(fetch).mockResolvedValue( + new Response(pdf, { + headers: { + 'content-type': 'application/pdf', + 'content-length': String(QUICKBOOKS_MAX_ATTACHMENT_BYTES + 1), + }, + }) + ) + + await expect( + executeQuickBooksDownloadDocument( + { + documentKind: 'transaction_pdf', + accessToken: 'secret-token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + transactionType: 'invoice', + transactionId: 'invoice-1', + }, + context() + ) + ).rejects.toThrow('QuickBooks transaction PDF') + expect(mocks.uploadCopilotFile).not.toHaveBeenCalled() + expect(mocks.uploadExecutionFile).not.toHaveBeenCalled() + }) + it('stores valid PDFs in trusted execution scope', async () => { const pdf = new TextEncoder().encode('%PDF-1.7\n') vi.mocked(fetch).mockResolvedValue( diff --git a/apps/sim/lib/internal/quickbooks/operations.ts b/apps/sim/lib/internal/quickbooks/operations.ts index 13a53ffb154..33707ff90a2 100644 --- a/apps/sim/lib/internal/quickbooks/operations.ts +++ b/apps/sim/lib/internal/quickbooks/operations.ts @@ -188,7 +188,7 @@ async function downloadQuickBooksTransactionPdf( headers: { ...buildQuickBooksHeaders(body.accessToken), Accept: 'application/pdf' }, signal: transferSignal, }) - if (!response.ok) throw await getQuickBooksDocumentError(response, signal) + if (!response.ok) throw await getQuickBooksDocumentError(response, transferSignal) const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() ?? '' diff --git a/apps/sim/lib/internal/quickbooks/provider-operations.test.ts b/apps/sim/lib/internal/quickbooks/provider-operations.test.ts new file mode 100644 index 00000000000..16c2fb1f4a4 --- /dev/null +++ b/apps/sim/lib/internal/quickbooks/provider-operations.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env', () => ({ + env: { QUICKBOOKS_ENV: 'production' }, +})) + +import { + executeQuickBooksCreateBillPaymentOperation, + executeQuickBooksUpdateRefundReceiptOperation, +} from '@/lib/internal/quickbooks/provider-operations' + +const AUTH = { + accessToken: 'token', + realmId: '123', + quickBooksEnvironment: 'sandbox', +} as const + +function billPaymentParams(paymentType: 'check' | 'credit_card') { + return { + ...AUTH, + vendorId: 'vendor-1', + paymentType, + paymentAccountId: 'account-1', + billAllocations: [{ billId: 'bill-1', amount: 10 }], + totalAmount: 10, + } +} + +describe('QuickBooks bill payment account compatibility', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('refuses a Bank account whose sub-type is not the documented Checking', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + Response.json({ + Account: { + Id: 'account-1', + SyncToken: '0', + AccountType: 'Bank', + AccountSubType: 'Savings', + }, + }) + ) + + await expect( + executeQuickBooksCreateBillPaymentOperation(billPaymentParams('check')) + ).rejects.toThrow('Checking sub-type') + expect(fetch).toHaveBeenCalledOnce() + }) + + it('refuses a Credit Card account whose sub-type is not the documented CreditCard', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + Response.json({ + Account: { + Id: 'account-1', + SyncToken: '0', + AccountType: 'Credit Card', + AccountSubType: 'LineOfCredit', + }, + }) + ) + + await expect( + executeQuickBooksCreateBillPaymentOperation(billPaymentParams('credit_card')) + ).rejects.toThrow('CreditCard sub-type') + expect(fetch).toHaveBeenCalledOnce() + }) + + it('accepts the documented Bank/Checking pair', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + Response.json({ + Account: { + Id: 'account-1', + SyncToken: '0', + AccountType: 'Bank', + AccountSubType: 'Checking', + }, + }) + ) + .mockResolvedValueOnce(Response.json({ BillPayment: { Id: 'pay-1', SyncToken: '0' } })) + + const result = await executeQuickBooksCreateBillPaymentOperation(billPaymentParams('check')) + expect(result.output.recordId).toBe('pay-1') + expect(fetch).toHaveBeenCalledTimes(2) + }) +}) + +describe('QuickBooks refund receipt sparse update', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('posts the documented sparse body without reading the record first', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + Response.json({ RefundReceipt: { Id: 'refund-1', SyncToken: '3' } }) + ) + + const result = await executeQuickBooksUpdateRefundReceiptOperation({ + ...AUTH, + transactionId: 'refund-1', + syncToken: '2', + lines: [{ lineType: 'item', amount: 10, itemId: 'item-1' }], + }) + + expect(fetch).toHaveBeenCalledOnce() + const [url, init] = vi.mocked(fetch).mock.calls[0] ?? [] + expect(String(url)).toContain('/refundreceipt') + expect(String(init?.method)).toBe('POST') + expect(JSON.parse(String(init?.body))).toEqual({ + Id: 'refund-1', + SyncToken: '2', + sparse: true, + Line: [ + { + Amount: 10, + DetailType: 'SalesItemLineDetail', + SalesItemLineDetail: { ItemRef: { value: 'item-1' } }, + }, + ], + }) + expect(result.output.syncToken).toBe('3') + }) +}) diff --git a/apps/sim/lib/internal/quickbooks/provider-operations.ts b/apps/sim/lib/internal/quickbooks/provider-operations.ts index c2060fac1ee..c6a2a644904 100644 --- a/apps/sim/lib/internal/quickbooks/provider-operations.ts +++ b/apps/sim/lib/internal/quickbooks/provider-operations.ts @@ -55,6 +55,23 @@ import { validateQuickBooksOptionalNumber, } from '@/tools/quickbooks/values' +/** + * Intuit constrains the BillPayment payment account by both classification + * fields, not by `AccountType` alone. `BillPaymentCheck.BankAccountRef`: "The + * specified account must have `Account.AccountType` set to `Bank` and + * `Account.AccountSubType` set to `Checking`." + * `BillPaymentCreditCard.CCAccountRef`: "The specified account must have + * `Account.AccountType` set to `Credit Card` and `Account.AccountSubType` set + * to `CreditCard`." + */ +const QUICKBOOKS_BILL_PAYMENT_ACCOUNTS = { + check: { label: 'Check', accountType: 'Bank', accountSubType: 'Checking' }, + credit_card: { label: 'Credit-card', accountType: 'Credit Card', accountSubType: 'CreditCard' }, +} as const satisfies Record< + QuickBooksCreateBillPaymentParams['paymentType'], + { label: string; accountType: string; accountSubType: string } +> + function assertCompatiblePaymentAccount( account: QuickBooksAccount, paymentType: QuickBooksCreateBillPaymentParams['paymentType'], @@ -68,10 +85,18 @@ function assertCompatiblePaymentAccount( throw new Error('QuickBooks payment account is inactive. Select an active account.') } - const expectedAccountType = paymentType === 'check' ? 'Bank' : 'Credit Card' - if (account.AccountType !== expectedAccountType) { + const expected = QUICKBOOKS_BILL_PAYMENT_ACCOUNTS[paymentType] + if (!expected) { + throw new Error(`Unsupported QuickBooks bill payment type: ${String(paymentType)}`) + } + if (account.AccountType !== expected.accountType) { + throw new Error( + `${expected.label} Bill Payments require a QuickBooks ${expected.accountType} account. Account ${paymentAccountId} is ${account.AccountType || 'missing an account type'}.` + ) + } + if (account.AccountSubType !== expected.accountSubType) { throw new Error( - `${paymentType === 'check' ? 'Check' : 'Credit-card'} Bill Payments require a QuickBooks ${expectedAccountType} account. Account ${paymentAccountId} is ${account.AccountType || 'missing an account type'}.` + `${expected.label} Bill Payments require a QuickBooks ${expected.accountType} account with the ${expected.accountSubType} sub-type. Account ${paymentAccountId} is ${account.AccountSubType || 'missing an account sub-type'}.` ) } } @@ -237,19 +262,32 @@ export function executeQuickBooksUpdateCreditMemoOperation( }) } -export function executeQuickBooksUpdateRefundReceiptOperation( +/** + * Intuit documents `RefundReceipt::UPDATE "Sparse update a refund receipt"`: + * "Sparse updating provides the ability to update a subset of properties for a + * given object; only elements specified in the request are updated. Missing + * elements are left untouched." The sparse operation is posted directly, so no + * read-merge-write round trip is needed to preserve untouched fields. + */ +export async function executeQuickBooksUpdateRefundReceiptOperation( params: QuickBooksUpdateRefundReceiptParams, signal?: AbortSignal ) { - return executeQuickBooksFullUpdate({ - params, + const response = await fetch(buildQuickBooksEntityUrl(params, 'refundreceipt'), { + method: 'POST', + headers: getQuickBooksToolHeaders(params.accessToken, 'application/json'), + body: JSON.stringify(buildQuickBooksUpdateSalesDocumentBody(params)), signal, - entity: 'RefundReceipt', - resource: 'refundreceipt', - recordId: params.transactionId, - syncToken: params.syncToken, - buildPatch: buildQuickBooksUpdateSalesDocumentBody, }) + if (!response.ok) { + throw await getQuickBooksOperationError(response, 'RefundReceipt', signal) + } + return transformQuickBooksMutationResponse( + response, + 'RefundReceipt', + undefined, + signal + ) } /** Preserves QuickBooks' all-or-none Payment lines across a full update. */ diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index a1a48008db5..72dbef3d016 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -268,13 +268,17 @@ export async function processDocument( mimeType.includes('json') || mimeType.includes('yaml') - if (isJsonYaml && JsonYamlChunker.isStructuredData(content)) { + const jsonYamlChunks = isJsonYaml + ? await JsonYamlChunker.chunkStructured(content, { + chunkSize, + minCharactersPerChunk, + maxChunks: MAX_DOCUMENT_CHUNKS, + }) + : null + + if (jsonYamlChunks !== null) { logger.info('Using JSON/YAML chunker for structured data') - chunks = await JsonYamlChunker.chunkJsonYaml(content, { - chunkSize, - minCharactersPerChunk, - maxChunks: MAX_DOCUMENT_CHUNKS, - }) + chunks = jsonYamlChunks } else if (StructuredDataChunker.isStructuredData(content, mimeType)) { logger.info('Using structured data chunker for spreadsheet/CSV content') const rowCount = metadata.totalRows ?? metadata.rowCount diff --git a/apps/sim/lib/microsoft-word/document.server.test.ts b/apps/sim/lib/microsoft-word/document.server.test.ts index 6c65b8eeafb..68ea543ddc4 100644 --- a/apps/sim/lib/microsoft-word/document.server.test.ts +++ b/apps/sim/lib/microsoft-word/document.server.test.ts @@ -4,6 +4,7 @@ import { Document, Header, Packer, Paragraph, TextRun } from 'docx' import JSZip from 'jszip' import { describe, expect, it } from 'vitest' +import { MAX_OOXML_CENTRAL_DIRECTORY_RECORDS, ZipBombError } from '@/lib/file-parsers/ooxml-limits' import { appendParagraphsToDocx, buildDocxFromContent, @@ -199,6 +200,21 @@ describe('extractDocxText', () => { await expect(extractDocxText(blank)).resolves.toBe('') }) + it('refuses an archive the OOXML guard rejects rather than rescuing it', async () => { + // The rescue re-opens the buffer with JSZip and reads `word/document.xml` + // into a string with no size cap, and the parser rejecting the archive is + // exactly what routes it there. The body stays empty so the rescue would + // otherwise succeed — reporting the archive as an empty document. + const zip = await JSZip.loadAsync(await buildDocxFromContent('')) + for (let index = 0; index <= MAX_OOXML_CENTRAL_DIRECTORY_RECORDS; index++) { + zip.file(`word/embeddings/pad${index}.bin`, '') + } + + await expect(extractDocxText(await zip.generateAsync({ type: 'nodebuffer' }))).rejects.toThrow( + ZipBombError + ) + }) + it('still fails on an archive that is not a Word package', async () => { const zip = new JSZip() zip.file('hello.txt', 'not a word document') diff --git a/apps/sim/lib/microsoft-word/document.server.ts b/apps/sim/lib/microsoft-word/document.server.ts index a950d8a2e4a..1f5f9669c56 100644 --- a/apps/sim/lib/microsoft-word/document.server.ts +++ b/apps/sim/lib/microsoft-word/document.server.ts @@ -249,6 +249,12 @@ export async function extractDocxText(buffer: Buffer): Promise { /** Whether the buffer is a valid Word package whose body holds no text. */ async function isEmptyWordPackage(buffer: Buffer): Promise { + // Reached with the untrusted buffer the parser just rejected — including when it + // rejected it as a zip bomb. Without this the rescue reads `word/document.xml` + // into a string with no size cap, making the guard the trigger for the expansion + // it prevents. Kept outside the catch so the rejection propagates. + assertOoxmlArchiveWithinLimits(buffer) + try { const zip = await JSZip.loadAsync(buffer) const part = zip.file(DOCUMENT_PART_PATH) diff --git a/apps/sim/lib/webhooks/providers/quickbooks.test.ts b/apps/sim/lib/webhooks/providers/quickbooks.test.ts index 28af6e2df1e..53363dae06e 100644 --- a/apps/sim/lib/webhooks/providers/quickbooks.test.ts +++ b/apps/sim/lib/webhooks/providers/quickbooks.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { quickBooksHandler, verifyQuickBooksSignature, + verifyQuickBooksSignatureAgainstVerifierTokenStream, verifyQuickBooksSignatureAgainstVerifierTokens, } from '@/lib/webhooks/providers/quickbooks' import { @@ -48,8 +49,72 @@ describe('QuickBooks webhook provider', () => { expect(isQuickBooksEventMatch('quickbooks_bill_events', event.type, ['updated'])).toBe(false) }) + it('stops decrypting verifier tokens once one matches the signature', async () => { + const body = JSON.stringify([event]) + const signature = crypto.createHmac('sha256', 'first-verifier').update(body).digest('base64') + const yielded: string[] = [] + async function* tokens(): AsyncGenerator { + for (const token of ['first-verifier', 'second-verifier']) { + yielded.push(token) + yield token + } + } + + expect( + await verifyQuickBooksSignatureAgainstVerifierTokenStream( + body, + signature, + tokens(), + 'request-stream-1' + ) + ).toBeNull() + expect(yielded).toEqual(['first-verifier']) + }) + + it('fails closed when no streamed verifier token matches', async () => { + const body = JSON.stringify([event]) + async function* tokens(): AsyncGenerator { + yield 'first-verifier' + } + async function* noTokens(): AsyncGenerator {} + + expect( + ( + await verifyQuickBooksSignatureAgainstVerifierTokenStream( + body, + 'invalid', + tokens(), + 'request-stream-2' + ) + )?.status + ).toBe(401) + expect( + ( + await verifyQuickBooksSignatureAgainstVerifierTokenStream( + body, + 'irrelevant', + noTokens(), + 'request-stream-3' + ) + )?.status + ).toBe(401) + expect( + ( + await verifyQuickBooksSignatureAgainstVerifierTokenStream( + body, + null, + tokens(), + 'request-stream-4' + ) + )?.status + ).toBe(401) + }) + it('normalizes Intuit void events to the configured voided action', async () => { - for (const entity of ['invoice', 'payment']) { + for (const [entity, entityType] of [ + ['invoice', 'Invoice'], + ['payment', 'Payment'], + ]) { const voidEvent = { ...event, type: `qbo.${entity}.void.v1` } expect( isQuickBooksEventMatch(`quickbooks_${entity}_events`, voidEvent.type, ['voided']) @@ -64,7 +129,7 @@ describe('QuickBooks webhook provider', () => { }) expect(result.input).toMatchObject({ eventType: `qbo.${entity}.void.v1`, - entityType: entity, + entityType, action: 'voided', }) } @@ -81,7 +146,7 @@ describe('QuickBooks webhook provider', () => { expect(result.input).toEqual({ eventId: 'event-1', eventType: 'qbo.invoice.updated.v1', - entityType: 'invoice', + entityType: 'Invoice', action: 'updated', entityId: '123', realmId: '456', diff --git a/apps/sim/lib/webhooks/providers/quickbooks.ts b/apps/sim/lib/webhooks/providers/quickbooks.ts index 66b978b6285..faacc304dbc 100644 --- a/apps/sim/lib/webhooks/providers/quickbooks.ts +++ b/apps/sim/lib/webhooks/providers/quickbooks.ts @@ -31,6 +31,11 @@ export function verifyQuickBooksSignature( ) } +function unauthorized(requestId: string, reason: string): NextResponse { + logger.warn(`[${requestId}] ${reason}`) + return new NextResponse('Unauthorized', { status: 401 }) +} + export function verifyQuickBooksSignatureAgainstVerifierTokens( rawBody: string, signature: string | null, @@ -41,12 +46,10 @@ export function verifyQuickBooksSignatureAgainstVerifierTokens( new Set(verifierTokens.map((token) => token.trim()).filter(Boolean)) ) if (configuredTokens.length === 0) { - logger.warn(`[${requestId}] QuickBooks webhook verifier token is not configured`) - return new NextResponse('Unauthorized', { status: 401 }) + return unauthorized(requestId, 'QuickBooks webhook verifier token is not configured') } if (!signature) { - logger.warn(`[${requestId}] QuickBooks webhook is missing intuit-signature`) - return new NextResponse('Unauthorized', { status: 401 }) + return unauthorized(requestId, 'QuickBooks webhook is missing intuit-signature') } const receivedSignature = signature.trim() @@ -56,12 +59,39 @@ export function verifyQuickBooksSignatureAgainstVerifierTokens( isValid = safeCompare(expected, receivedSignature) || isValid } if (!isValid) { - logger.warn(`[${requestId}] QuickBooks webhook signature verification failed`) - return new NextResponse('Unauthorized', { status: 401 }) + return unauthorized(requestId, 'QuickBooks webhook signature verification failed') } return null } +/** + * Verifies the delivery against verifier tokens produced one at a time, stopping at the first + * match so an app-level webhook does not decrypt every connected account before acknowledging. + */ +export async function verifyQuickBooksSignatureAgainstVerifierTokenStream( + rawBody: string, + signature: string | null, + verifierTokens: AsyncIterable, + requestId: string +): Promise { + if (!signature) { + return unauthorized(requestId, 'QuickBooks webhook is missing intuit-signature') + } + + const receivedSignature = signature.trim() + let sawConfiguredToken = false + for await (const verifierToken of verifierTokens) { + const trimmedToken = verifierToken.trim() + if (!trimmedToken) continue + sawConfiguredToken = true + if (safeCompare(hmacSha256Base64(rawBody, trimmedToken), receivedSignature)) return null + } + if (!sawConfiguredToken) { + return unauthorized(requestId, 'QuickBooks webhook verifier token is not configured') + } + return unauthorized(requestId, 'QuickBooks webhook signature verification failed') +} + function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null return value as Record @@ -131,14 +161,17 @@ export const quickBooksHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { const event = asRecord(body) ?? {} const eventType = typeof event.type === 'string' ? event.type : '' - const { parseQuickBooksWebhookType } = await import('@/triggers/quickbooks/quickbooks') + const { getQuickBooksTriggerDefinitionByEntity, parseQuickBooksWebhookType } = await import( + '@/triggers/quickbooks/quickbooks' + ) const parsed = parseQuickBooksWebhookType(eventType) + const definition = parsed ? getQuickBooksTriggerDefinitionByEntity(parsed.entity) : undefined return { input: { eventId: typeof event.id === 'string' ? event.id : '', eventType, - entityType: parsed?.entity ?? '', + entityType: definition?.entityType ?? '', action: parsed?.action ?? '', entityId: typeof event.intuitentityid === 'string' ? event.intuitentityid : '', realmId: typeof event.intuitaccountid === 'string' ? event.intuitaccountid : '', diff --git a/apps/sim/lib/webhooks/quickbooks-credentials.test.ts b/apps/sim/lib/webhooks/quickbooks-credentials.test.ts index a28178e2f20..08fe008ca42 100644 --- a/apps/sim/lib/webhooks/quickbooks-credentials.test.ts +++ b/apps/sim/lib/webhooks/quickbooks-credentials.test.ts @@ -20,9 +20,17 @@ import { buildQuickBooksWebhookAccountIdPattern, buildQuickBooksWebhookRoutingKey, getQuickBooksWebhookClientConfigByCredentialId, - getQuickBooksWebhookVerifierTokensByAppKey, + streamQuickBooksWebhookVerifierTokensByAppKey, } from '@/lib/webhooks/quickbooks-credentials' +async function collectVerifierTokens(appKey: string): Promise { + const tokens: string[] = [] + for await (const token of streamQuickBooksWebhookVerifierTokensByAppKey(appKey)) { + tokens.push(token) + } + return tokens +} + const CLIENT_CONFIG: QuickBooksOAuthClientConfig = { clientId: 'client-id', clientSecret: 'client-secret', @@ -51,9 +59,7 @@ describe('QuickBooks webhook credential lookup', () => { }, ]) - await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).resolves.toEqual([ - 'verifier-token', - ]) + await expect(collectVerifierTokens(APP_KEY)).resolves.toEqual(['verifier-token']) expect(mockDecryptSecret).toHaveBeenCalledWith('encrypted-config') }) @@ -73,12 +79,29 @@ describe('QuickBooks webhook credential lookup', () => { decrypted: JSON.stringify({ ...CLIENT_CONFIG, webhookVerifierToken: 'second-verifier' }), }) - await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).resolves.toEqual([ + await expect(collectVerifierTokens(APP_KEY)).resolves.toEqual([ 'first-verifier', 'second-verifier', ]) }) + it('decrypts one account at a time so an early match skips the rest of the app', async () => { + queueTableRows( + account, + Array.from({ length: 10 }, (_, index) => ({ + accountId: createQuickBooksAccountId(String(index + 1), `subject-${index}`, CLIENT_CONFIG), + oauthConfig: 'encrypted-config', + })) + ) + + for await (const token of streamQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)) { + expect(token).toBe('verifier-token') + break + } + + expect(mockDecryptSecret).toHaveBeenCalledTimes(1) + }) + it('fails closed instead of loading an unbounded number of app accounts', async () => { queueTableRows( account, @@ -88,7 +111,7 @@ describe('QuickBooks webhook credential lookup', () => { })) ) - await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).rejects.toThrow( + await expect(collectVerifierTokens(APP_KEY)).rejects.toThrow( 'QuickBooks webhook app account limit exceeded' ) expect(dbChainMockFns.limit).toHaveBeenCalledWith(1001) @@ -128,7 +151,7 @@ describe('QuickBooks webhook credential lookup', () => { decrypted: JSON.stringify({ ...CLIENT_CONFIG, clientId: 'different-app' }), }) - await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).resolves.toEqual([]) + await expect(collectVerifierTokens(APP_KEY)).resolves.toEqual([]) }) it('escapes wildcard characters in the app-scoped account lookup', async () => { diff --git a/apps/sim/lib/webhooks/quickbooks-credentials.ts b/apps/sim/lib/webhooks/quickbooks-credentials.ts index ed73bc31108..b7d1b3b539d 100644 --- a/apps/sim/lib/webhooks/quickbooks-credentials.ts +++ b/apps/sim/lib/webhooks/quickbooks-credentials.ts @@ -59,10 +59,14 @@ async function decryptValidatedClientConfig( } } -/** Loads every verifier token configured for the Intuit app addressed by its non-secret route key. */ -export async function getQuickBooksWebhookVerifierTokensByAppKey( +/** + * Yields every distinct verifier token configured for the Intuit app addressed by its non-secret + * route key, decrypting one account at a time so a caller that stops at the first match never pays + * for the whole app's fan-out. + */ +export async function* streamQuickBooksWebhookVerifierTokensByAppKey( appKey: string -): Promise { +): AsyncGenerator { const normalizedAppKey = normalizeQuickBooksWebhookAppKey(appKey) const rows = await db .select({ @@ -82,16 +86,18 @@ export async function getQuickBooksWebhookVerifierTokensByAppKey( throw new Error('QuickBooks webhook app account limit exceeded') } - const verifierTokens = new Set() + const yieldedTokens = new Set() for (const row of rows) { const config = await decryptValidatedClientConfig( row.accountId, row.oauthConfig, normalizedAppKey ) - if (config) verifierTokens.add(config.webhookVerifierToken) + const verifierToken = config?.webhookVerifierToken + if (!verifierToken || yieldedTokens.has(verifierToken)) continue + yieldedTokens.add(verifierToken) + yield verifierToken } - return Array.from(verifierTokens) } /** Loads the user-owned Intuit app configuration behind one QuickBooks OAuth credential. */ diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index 84483755628..a8a1222f546 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -735,6 +735,166 @@ describe('migrateSubblockIds', () => { }) }) + describe('quickbooks block', () => { + function quickbooksBlock(subBlocks: Record) { + return { + b1: makeBlock({ + type: 'quickbooks', + subBlocks: subBlocks as BlockState['subBlocks'], + }), + } + } + + it('moves a by-ID read target onto readTransactionId', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_read_purchasing_transactions', + }, + readMode: { id: 'readMode', type: 'dropdown', value: 'by_id' }, + transactionId: { id: 'transactionId', type: 'short-input', value: '5' }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.readTransactionId.value).toBe('5') + expect(blocks.b1.subBlocks.transactionId).toBeUndefined() + }) + + it('moves the sales and accounting by-ID read targets too', () => { + for (const operation of [ + 'quickbooks_read_sales_transactions', + 'quickbooks_read_accounting_transactions', + ]) { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { id: 'operation', type: 'dropdown', value: operation }, + transactionId: { id: 'transactionId', type: 'short-input', value: '7' }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.readTransactionId.value).toBe('7') + } + }) + + it('leaves an update target on transactionId', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_update_purchase_order', + }, + transactionId: { id: 'transactionId', type: 'short-input', value: '5' }, + }) + ) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.transactionId.value).toBe('5') + expect(blocks.b1.subBlocks.readTransactionId).toBeUndefined() + }) + + it('leaves a void target on transactionId', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { id: 'operation', type: 'dropdown', value: 'quickbooks_void_invoice' }, + transactionId: { id: 'transactionId', type: 'short-input', value: '9' }, + }) + ) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.transactionId.value).toBe('9') + expect(blocks.b1.subBlocks.readTransactionId).toBeUndefined() + }) + + it('recovers each retired summarize-columns subset onto reportSummarizeBy', () => { + for (const [from, value] of [ + ['reportCustomerSalesSummarizeBy', 'item'], + ['reportVendorExpenseSummarizeBy', 'vendor'], + ['reportTimeSummarizeBy', 'quarter'], + ] as const) { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_run_financial_report', + }, + [from]: { id: from, type: 'dropdown', value }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.reportSummarizeBy.value).toBe(value) + expect(blocks.b1.subBlocks[from]).toBeUndefined() + } + }) + + it('never clobbers a reportSummarizeBy value that is already set', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_run_financial_report', + }, + reportSummarizeBy: { id: 'reportSummarizeBy', type: 'dropdown', value: 'month' }, + reportCustomerSalesSummarizeBy: { + id: 'reportCustomerSalesSummarizeBy', + type: 'dropdown', + value: 'item', + }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.reportSummarizeBy.value).toBe('month') + expect(blocks.b1.subBlocks.reportCustomerSalesSummarizeBy).toBeUndefined() + }) + + it('moves the download-side file name onto downloadAttachmentFileName', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_download_attachment', + }, + attachmentFileName: { + id: 'attachmentFileName', + type: 'short-input', + value: 'receipt.pdf', + }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.downloadAttachmentFileName.value).toBe('receipt.pdf') + expect(blocks.b1.subBlocks.attachmentFileName).toBeUndefined() + }) + + it('leaves the add-side file name on attachmentFileName', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { id: 'operation', type: 'dropdown', value: 'quickbooks_add_attachment' }, + attachmentKind: { id: 'attachmentKind', type: 'dropdown', value: 'file' }, + attachmentFileName: { + id: 'attachmentFileName', + type: 'short-input', + value: 'receipt.pdf', + }, + }) + ) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.attachmentFileName.value).toBe('receipt.pdf') + expect(blocks.b1.subBlocks.downloadAttachmentFileName).toBeUndefined() + }) + }) + it('should handle blocks with empty subBlocks', () => { const input: Record = { b1: makeBlock({ type: 'knowledge', subBlocks: {} }), diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index c12c94fcab8..8abccdd327f 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -307,6 +307,58 @@ export const SUBBLOCK_ID_MIGRATIONS: Record