From f4fb237b8640b1c5642481ae91887c77f6bc0adf Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 5 Sep 2026 13:12:50 -0700 Subject: [PATCH 01/14] fix(knowledge): bound JSON/YAML chunker expansion (#7524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(knowledge): bound JSON/YAML chunker expansion JsonYamlChunker re-parsed and re-serialized document content with no expansion limit. ChunkBudget only counts emitted chunks, so every parse and full-object stringify ran before it could fire — a small aliased YAML source expands to tens of MB, and the same content was parsed twice because isStructuredData and chunkJsonYaml each parsed it. - Measure the parsed value with the shared measureYamlExpansion guard before anything materializes it, and skip parsing entirely when the source is already larger than the ceiling - Size the ceiling to the most text the chunker could ever emit (maxChunks x chunkSize), floored at 4MB and capped at what the YAML file parser itself permits, so documents that fit the budget chunk exactly as before - Replace isStructuredData + chunkJsonYaml with one chunkStructured entry point that parses once and returns null when the content is not structured, leaving chunker selection with the document processor * fix(knowledge): allow proportionate expansion in the chunker guard Comparing the expansion estimate against the output budget mixed two units: measureYamlExpansion charges a flat per-node allowance, so a document of many small values is charged several times its pretty-printed size and was rejected even though its chunks fit the budget — a flat array of a million booleans is charged ~22MB against ~8MB of real output. Allow the larger of two admissible expansions: one that fits the output budget, and one proportionate to the source. Alias expansion overshoots its source by orders of magnitude, so it is still rejected, while an ordinary large document is no longer charged for the estimator's conservatism. Neither allowance ever exceeds the file parser's own cap. --- .../lib/chunkers/json-yaml-chunker.test.ts | 130 ++++++++++----- apps/sim/lib/chunkers/json-yaml-chunker.ts | 149 +++++++++++++++--- apps/sim/lib/file-parsers/yaml-parser.ts | 2 +- .../knowledge/documents/document-processor.ts | 16 +- 4 files changed, 234 insertions(+), 63 deletions(-) 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/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/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 From c9945ef158ecd0608d3a2fedc65cae2192c8a2c4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 5 Sep 2026 13:13:43 -0700 Subject: [PATCH 02/14] fix(security): guard two OOXML paths that reached JSZip without a zip-bomb check (#7526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractDocxText` rescues a parse failure by re-reading the package as a possibly-empty document, and that rescue handed the buffer to JSZip with no size guard — so a zip bomb, whose rejection is exactly what routes it there, got its `word/document.xml` read into a string uncapped. Guard the rescue the way every other JSZip call site in the file already does, outside the catch so the rejection propagates. `extractDocAssets` handed an attacker-supplied .pptx/.docx straight to JSZip and inflated every media entry into a retained Buffer, with only a compressed-size ceiling upstream. Apply the same shared guard the document parsers and `extractDocumentStyle` already apply to the same class of input. No new limits are introduced — both call sites now use the existing shared ceilings, so a file rejected here is one Sim's text-extraction path already rejects today. Claude-Session: https://claude.ai/code/session_01VmwJP8EmSo3KcMFoKjpd6y Co-authored-by: Claude Opus 5 (1M context) --- .../tools/server/files/doc-asset-extract.test.ts | 16 ++++++++++++++++ .../tools/server/files/doc-asset-extract.ts | 6 ++++++ .../lib/microsoft-word/document.server.test.ts | 16 ++++++++++++++++ apps/sim/lib/microsoft-word/document.server.ts | 6 ++++++ 4 files changed, 44 insertions(+) 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/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) From 07f919071ffb0a7897adb216c0afe733644af6e4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 5 Sep 2026 15:08:00 -0700 Subject: [PATCH 03/14] fix(chat): bound deployed-chat callers and stop leaking chat gate config (#7525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat): bound deployed-chat callers and stop leaking chat gate config Two authorization/throttling defects on chat deployments. **Denial of wallet on POST /api/chat/[identifier].** A deployed chat resolves its execution principal from the workflow's workspace, so the plan rate bucket, the usage/credit check and the concurrency reservation all belong to the owner while the request belongs to whoever found the link. Nothing bounded the caller, and an abort refunds none of it. Both the per-IP and the per-deployment bucket now run after auth and before `preprocessExecution`, on every execution regardless of `authType` — an email or SSO visitor is still not the payer. `GET /api/chat/validate` answered for any anonymous caller, so `available:false` inventoried live deployments; it now needs a session and a per-user bucket. **Chat gate config exposed at workflow `read` on GET /api/workflows/[id]/chat/ status.** The route reimplemented the admin-gated detail projection inline, serving the `allowedEmails` allow-list, `hasPassword` and the customization blob to any workspace viewer, and asserting no `deploy.chat` capability. It is now an adapter over `chat_deployments.list` — the same operation `GET /api/v2/chat- deployments` binds — returning only the deployment's id and identifier, which is all the editor reads before fetching the detail from `/api/chat/manage/{id}`. The two buckets are the existing `enforceIpRateLimitWithIndependentBackstop` plus a new `enforceResourceRateLimit` beside its siblings in `route-helpers`. The IP bucket is consulted first and returns on refusal, so one flooding IP cannot drain the deployment's budget and 429 the real audience with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QWh9WFMYNZ6uTFQFUzF8Bj * fix(chat): drop the env knobs and put the ceiling under the plan bucket Two corrections to the execution throttle. The per-deployment ceiling was 300/min, at or above the workspace `sync` counter it debits on every plan but enterprise — 50 free, 150 pro, 300 team. A flood therefore drained that shared counter, which the owner's API, webhook and scheduled runs draw from too, before the ceiling ever refused: the availability half of the report went unmitigated on exactly the plans most workspaces are on. It is now 60/min sustained, under even the cheapest paid plan, with a test that pins it there against `RATE_LIMITS`. The per-IP bucket drops to 30/min so one host cannot take a deployment's whole allowance, and both gain the 2x burst allowance the plan buckets already use. Both limits go back to plain constants. Every sibling deployment throttle — password, OTP, SSO, on chat and on public file shares — is a hardcoded `TokenBucketConfig`, so the two env vars were the only configurable ones of their kind and bought speculative tuning for a control with sane defaults. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QWh9WFMYNZ6uTFQFUzF8Bj * fix(chat): derive the chat ceiling from the plan table it must stay under The 60/min ceiling still sat above the free plan's 50/min sync rate, so on free the shared counter — the one the owner's API, webhook and scheduled runs also draw from — still emptied before the ceiling refused. Every plan rate is also operator-overridable through `RATE_LIMIT_*_SYNC`, which no hardcoded number can track. It is now derived: 80% of the smallest configured plan sync rate, which is 40/min with the defaults and stays under every plan by construction. The per-IP bucket follows at half that. Tests assert the invariant against each plan in `RATE_LIMITS`, on burst as well as sustained rate, rather than pinning numbers that would need editing the next time a plan default moves. This floor is shared by all plans, so enterprise is held to the same 40/min as free. Sizing the slice to the payer's own plan needs the subscription, which `preprocessExecution` resolves just after this runs — that is the follow-up, and the same hook bounds the generic-webhook surface that is still unbounded. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QWh9WFMYNZ6uTFQFUzF8Bj * docs(chat): note the one plan rate where the derived ceiling lands equal Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QWh9WFMYNZ6uTFQFUzF8Bj * refactor(rate-limit): scope the per-IP bucket by resource id, not by bucket name The chat call interpolated the deployment id into `bucketName`, which produces a correct key but puts a per-deployment value into the field both log lines emit as `bucket` — high cardinality on a label meant to name a bucket family, and asymmetric with the `enforceResourceRateLimit` call beside it that takes the id as its own argument. `enforceIpRateLimitWithIndependentBackstop` now takes an optional `resourceId`, so the pair reads the same way and `resourceId` is logged as its own field. The unscoped key shape is unchanged for the existing callers, with a test pinning both shapes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QWh9WFMYNZ6uTFQFUzF8Bj * test(rate-limit): drop needless any casts on the mock request createMockRequest already returns NextRequest, so the casts weakened the helper's input contract in the new tests for nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QWh9WFMYNZ6uTFQFUzF8Bj --------- Co-authored-by: Claude Opus 5 (1M context) --- .../app/api/chat/[identifier]/route.test.ts | 120 +++++++++- apps/sim/app/api/chat/[identifier]/route.ts | 73 ++++++ apps/sim/app/api/chat/validate/route.test.ts | 75 ++++++ apps/sim/app/api/chat/validate/route.ts | 32 ++- .../workflows/[id]/chat/status/route.test.ts | 221 ++++++++++++------ .../api/workflows/[id]/chat/status/route.ts | 115 +++------ apps/sim/hooks/queries/deployments.ts | 6 +- apps/sim/lib/api/contracts/deployments.ts | 16 +- .../lib/chat-deployments/application/index.ts | 2 + .../application/workflow-chat-deployment.ts | 38 +++ apps/sim/lib/core/rate-limiter/index.ts | 1 + .../core/rate-limiter/route-helpers.test.ts | 75 ++++++ .../lib/core/rate-limiter/route-helpers.ts | 46 +++- 13 files changed, 655 insertions(+), 165 deletions(-) create mode 100644 apps/sim/app/api/chat/validate/route.test.ts 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/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/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/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/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 From be408ee1ac36e56949c98e868c7642dfb4cb829a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:35:08 -0700 Subject: [PATCH 04/14] feat(models): gate forced tool use by model capability (#7528) * feat(models): gate forced tool use by model capability * fix(models): derive forced tool support from capability metadata * refactor(models): use catalog capabilities for forced tool support --- .../components/tool-input/tool-input.tsx | 10 +++++-- .../providers/anthropic/core.request.test.ts | 2 +- apps/sim/providers/anthropic/core.ts | 14 ++-------- apps/sim/providers/models.test.ts | 27 +++++++++++++++++++ apps/sim/providers/models.ts | 9 +++++++ 5 files changed, 47 insertions(+), 15 deletions(-) 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)'} + { expect(payload.tool_choice).toEqual({ type: 'tool', name: 'publish' }) }) - it('drops forced tool_choice on Claude Fable 5.1 because the API rejects it', async () => { + it('drops forced tool_choice when the catalog model disables Force', async () => { const { payload, warn } = await runWithForcedTool('claude-fable-5-1') expect(payload.tools?.map((tool) => tool.name)).toEqual(['publish']) expect(payload).not.toHaveProperty('tool_choice') diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 36dbd6ca534..978dd1bc4f5 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -21,6 +21,7 @@ import { import { getMaxOutputTokensForModel, getThinkingCapability, + supportsForcedToolUse, supportsNativeStructuredOutputs, supportsTemperature, } from '@/providers/models' @@ -155,17 +156,6 @@ function supportsAdaptiveThinking(modelId: string): boolean { ) } -/** - * Claude Fable 5.1 and Claude Mythos 5.1 reject forced tool use: a `tool_choice` of - * type `tool` or `any` returns a 400 (`tool_choice: type "tool" and "any" are not - * supported for this model.`). Thinking is always on for these models, so a forced - * call would skip it. The request is sent with the default `auto` instead. - */ -function rejectsForcedToolChoice(modelId: string): boolean { - const normalizedModel = modelId.toLowerCase() - return normalizedModel.includes('fable-5-1') || normalizedModel.includes('mythos-5-1') -} - /** * Builds the thinking configuration for the Anthropic API based on model capabilities and level. * @@ -428,7 +418,7 @@ export async function executeAnthropicProviderRequest( } else if (toolChoice === 'none') { payload.tool_choice = { type: 'none' } } else if (toolChoice !== 'auto') { - if (rejectsForcedToolChoice(request.model)) { + if (!supportsForcedToolUse(request.model)) { logger.warn( `Model ${modelId} rejects forced tool_choice; sending tool "${toolChoice.name}" with tool_choice auto` ) diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index dbf32ffdae7..d6fa3568d05 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { getBaseModelProviders, getHostedModels, + getModelCapabilities, getModelPricing, getModelsWithPromptCaching, getPromptCachingMinimumTokens, @@ -13,6 +14,7 @@ import { isModelDeprecated, orderModelIdsByReleaseDate, PROVIDER_DEFINITIONS, + supportsForcedToolUse, updateFireworksModels, } from '@/providers/models' import { supportsPromptCaching } from '@/providers/utils' @@ -61,6 +63,31 @@ describe('catalog featured model metadata', () => { }) }) +describe('forced tool use capability', () => { + it.each(['claude-fable-5-1', 'CLAUDE-FABLE-5-1'])( + 'disables Force while keeping Auto and None support for %s', + (model) => { + expect(getModelCapabilities(model)).toMatchObject({ + toolUsageControl: true, + forcedToolUse: false, + }) + expect(supportsForcedToolUse(model)).toBe(false) + } + ) + + it.each(['claude-sonnet-5', 'claude-fable-5', 'claude-opus-5', 'gpt-5.5'])( + 'inherits provider tool-control support for %s', + (model) => { + expect(supportsForcedToolUse(model)).toBe(true) + } + ) + + it('does not enable Force for an unknown model without tool-control capabilities', () => { + expect(getModelCapabilities('unknown-model')).toBeNull() + expect(supportsForcedToolUse('unknown-model')).toBe(false) + }) +}) + describe('Anthropic thinking stream visibility', () => { it('classifies visible Claude thinking as summarized rather than raw', () => { for (const providerId of ['anthropic', 'azure-anthropic'] as const) { diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 84e22f4a81c..37ccde6c448 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -45,6 +45,8 @@ export interface ModelCapabilities { max: number } toolUsageControl?: boolean + /** Whether tools can be forced. Defaults to toolUsageControl when omitted. */ + forcedToolUse?: boolean computerUse?: boolean nativeStructuredOutputs?: boolean /** Maximum supported output tokens for this model */ @@ -914,6 +916,7 @@ export const PROVIDER_DEFINITIONS: Record = { updatedAt: '2026-09-04', }, capabilities: { + forcedToolUse: false, nativeStructuredOutputs: true, maxOutputTokens: 128000, promptCaching: { minimumCacheableTokens: 512 }, @@ -4481,6 +4484,12 @@ export function supportsToolUsageControl(providerId: string): boolean { return getProvidersWithToolUsageControl().includes(providerId) } +/** Whether the model accepts forced tool choice. */ +export function supportsForcedToolUse(modelId: string): boolean { + const capabilities = getModelCapabilities(modelId) + return capabilities?.forcedToolUse ?? capabilities?.toolUsageControl ?? false +} + export function updateOllamaModels(models: string[]): void { PROVIDER_DEFINITIONS.ollama.models = models.map((modelId) => ({ id: modelId, From 5988ceec67cbc680dd685a1bd526a8609d7cad07 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 5 Sep 2026 16:48:50 -0700 Subject: [PATCH 05/14] fix(executor): preserve disabled-branch execution and join readiness (#7537) --- .../hooks/use-workflow-execution.test.tsx | 137 +++++++++++- .../hooks/use-workflow-execution.ts | 44 ++-- apps/sim/executor/dag/construction/edges.ts | 2 + .../executor/execution/edge-manager.test.ts | 198 ++++++++++++++++- apps/sim/executor/execution/edge-manager.ts | 35 ++- .../condition/condition-handler.test.ts | 209 +++++++++++++++++- .../handlers/router/router-handler.test.ts | 114 +++++++++- apps/sim/serializer/index.test.ts | 202 ++++++++++++++++- apps/sim/serializer/index.ts | 1 + 9 files changed, 904 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index 3677c7b57f1..f40f8ffb330 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -16,6 +16,7 @@ const { mockEndScopedExecution, mockExecute, mockExecuteFromBlock, + mockFindStartBlock, mockFetch, mockHandleExecutionCancelledConsole, mockHandleExecutionErrorConsole, @@ -101,6 +102,7 @@ const { mockEndScopedExecution: vi.fn(() => 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/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/serializer/index.test.ts b/apps/sim/serializer/index.test.ts index 9a946e9d858..02ed7c18ee3 100644 --- a/apps/sim/serializer/index.test.ts +++ b/apps/sim/serializer/index.test.ts @@ -10,20 +10,45 @@ import { createAgentWithToolsWorkflowState, + createBlock, createComplexWorkflowState, createConditionalWorkflowState, createInvalidSerializedWorkflow, createInvalidWorkflowState, + createLoopBlock, createLoopWorkflowState, createMinimalWorkflowState, createMissingMetadataWorkflow, + createParallelBlock, } from '@sim/testing/factories' -import { blocksMock, toolsMetadataMock, toolsUtilsMock } from '@sim/testing/mocks' +import { + blocksMock, + createMockGetBlock, + mockBlockConfigs, + toolsMetadataMock, + toolsUtilsMock, +} from '@sim/testing/mocks' import { describe, expect, it, vi } from 'vitest' +import { DAGBuilder } from '@/executor/dag/builder' import { Serializer } from '@/serializer/index' import type { SerializedWorkflow } from '@/serializer/types' -vi.mock('@/blocks', () => blocksMock) +vi.mock('@/blocks', () => ({ + ...blocksMock, + getBlock: createMockGetBlock({ + condition: { + ...mockBlockConfigs.condition, + subBlocks: [ + ...mockBlockConfigs.condition.subBlocks, + { id: 'conditions', type: 'condition-input' }, + ], + }, + router_v2: { + ...mockBlockConfigs.condition, + subBlocks: [{ id: 'routes', type: 'router-input' }], + }, + }), +})) vi.mock('@/tools/utils', () => toolsUtilsMock) vi.mock('@/tools/metadata', () => toolsMetadataMock) @@ -83,6 +108,179 @@ describe('Serializer', () => { expect(falsePathConnection?.target).toBe('agent2') }) + it.concurrent.each(['agent1', 'condition1'])( + 'should keep disabled block %s and its incoming and outgoing connections', + (disabledBlockId) => { + const { blocks, edges, loops } = createConditionalWorkflowState() + const serializer = new Serializer() + + blocks[disabledBlockId].enabled = false + + const serialized = serializer.serializeWorkflow(blocks, edges, loops) + + expect(serialized.blocks.find((b) => b.id === disabledBlockId)?.enabled).toBe(false) + expect(serialized.connections).toEqual([ + { source: 'starter', target: 'condition1' }, + { source: 'condition1', target: 'agent1', sourceHandle: 'condition-true' }, + { source: 'condition1', target: 'agent2', sourceHandle: 'condition-false' }, + ]) + } + ) + + it.concurrent.each([ + { blockType: 'condition', configKey: 'conditions' }, + { blockType: 'router_v2', configKey: 'routes' }, + ] as const)( + 'should ignore disabled $blockType configuration when inferring legacy route handles', + ({ blockType, configKey }) => { + const { blocks, edges } = createMinimalWorkflowState() + blocks.disabled = createBlock({ + id: 'disabled', + type: blockType, + enabled: false, + subBlocks: { + [configKey]: { + id: configKey, + type: blockType === 'condition' ? 'condition-input' : 'router-input', + value: '[null]', + }, + }, + }) + edges.push({ id: 'disabled-edge', source: 'disabled', target: 'agent1' }) + + const serialized = new Serializer().serializeWorkflow(blocks, edges) + const dag = new DAGBuilder().build(serialized, { triggerBlockId: 'starter' }) + + expect(serialized.blocks.find((block) => block.id === 'disabled')?.enabled).toBe(false) + expect(serialized.connections).toHaveLength(2) + expect(dag.nodes.has('disabled')).toBe(false) + expect(dag.nodes.get('agent1')!.incomingEdges).toEqual(new Set(['starter'])) + } + ) + + it.concurrent('should preserve connections that reference a block that does not exist', () => { + const { blocks, edges, loops } = createConditionalWorkflowState() + const serializer = new Serializer() + + const { agent1: _removed, ...remainingBlocks } = blocks + + const serialized = serializer.serializeWorkflow(remainingBlocks, edges, loops) + + expect(serialized.blocks.find((b) => b.id === 'agent1')).toBeUndefined() + expect(serialized.connections).toEqual([ + { source: 'starter', target: 'condition1' }, + { source: 'condition1', target: 'agent1', sourceHandle: 'condition-true' }, + { source: 'condition1', target: 'agent2', sourceHandle: 'condition-false' }, + ]) + }) + + it.concurrent( + 'should preserve connections from a missing source without changing valid edges', + () => { + const { blocks, edges, loops } = createMinimalWorkflowState() + edges.push({ id: 'dangling', source: 'missing', target: 'agent1' }) + + const serialized = new Serializer().serializeWorkflow(blocks, edges, loops) + + expect(serialized.connections).toEqual([ + { source: 'starter', target: 'agent1' }, + { source: 'missing', target: 'agent1' }, + ]) + expect(edges).toHaveLength(2) + expect(Object.keys(blocks)).toEqual(['starter', 'agent1']) + } + ) + + it.concurrent.each([ + { blockType: 'condition', configKey: 'conditions', handlePrefix: 'condition-' }, + { blockType: 'router_v2', configKey: 'routes', handlePrefix: 'router-' }, + ] as const)( + 'should preserve inferred $blockType route handles when an earlier target is missing', + ({ blockType, configKey, handlePrefix }) => { + const { blocks } = createMinimalWorkflowState() + blocks.branch = createBlock({ + id: 'branch', + type: blockType, + subBlocks: { + [configKey]: { + id: configKey, + type: blockType === 'condition' ? 'condition-input' : 'router-input', + value: JSON.stringify([ + { id: 'first', title: 'if', value: 'true' }, + { id: 'second', title: 'else', value: '' }, + ]), + }, + }, + }) + const edges = [ + { id: 'entry', source: 'starter', target: 'branch' }, + { id: 'first', source: 'branch', target: 'missing' }, + { id: 'second', source: 'branch', target: 'agent1' }, + ] + + const serialized = new Serializer().serializeWorkflow(blocks, edges) + const dag = new DAGBuilder().build(serialized, { triggerBlockId: 'starter' }) + + expect(serialized.connections).toEqual([ + { source: 'starter', target: 'branch' }, + { source: 'branch', target: 'missing' }, + { source: 'branch', target: 'agent1' }, + ]) + expect(Array.from(dag.nodes.get('branch')!.outgoingEdges.values())).toEqual([ + expect.objectContaining({ + target: 'agent1', + sourceHandle: `${handlePrefix}second`, + }), + ]) + expect(edges[2]).not.toHaveProperty('sourceHandle') + } + ) + + it.concurrent.each(['loop', 'parallel'] as const)( + 'should preserve connections to and from a %s container', + (containerType) => { + const { blocks } = createMinimalWorkflowState() + blocks.container = + containerType === 'loop' + ? createLoopBlock({ id: 'container' }) + : createParallelBlock({ id: 'container' }) + blocks.agent1.data = { parentId: 'container' } + const edges = [ + { id: 'entry', source: 'starter', target: 'container' }, + { + id: 'body', + source: 'container', + target: 'agent1', + sourceHandle: `${containerType}-start-source`, + }, + { + id: 'end', + source: 'agent1', + target: 'container', + targetHandle: `${containerType}-end-target`, + }, + ] + + const serialized = new Serializer().serializeWorkflow(blocks, edges) + + expect(serialized.connections).toEqual([ + { source: 'starter', target: 'container' }, + { + source: 'container', + target: 'agent1', + sourceHandle: `${containerType}-start-source`, + }, + { + source: 'agent1', + target: 'container', + targetHandle: `${containerType}-end-target`, + }, + ]) + const containers = containerType === 'loop' ? serialized.loops : serialized.parallels + expect(containers?.container.nodes).toEqual(['agent1']) + } + ) + it.concurrent('should serialize a workflow with loops correctly', () => { const { blocks, edges, loops } = createLoopWorkflowState() const serializer = new Serializer() diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index c5239d0709e..a332a8d014d 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -194,6 +194,7 @@ export class Serializer { return { version: '1.0', blocks: serializedBlocks, + /** Legacy handleless branches infer route IDs from edge order, including missing targets. */ connections: edges .filter((edge) => !droppedBlockIds.has(edge.source) && !droppedBlockIds.has(edge.target)) .map((edge) => ({ From a0f38dbc783a52682b8c5f0c85e33a02a8fb15f7 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:20:15 -0700 Subject: [PATCH 06/14] feat(editor): replace canonical input toggle with icon switch (#7527) * feat(editor): replace canonical mode toggle with icon switch * fix(emcn): avoid icon switch package import cycle --- .../components/canonical-mode-toggle.tsx | 28 +++++ .../components/sub-block/components/index.ts | 1 + .../editor/components/sub-block/sub-block.tsx | 46 ++----- .../icon-switch/icon-switch.test.tsx | 112 ++++++++++++++++++ .../components/icon-switch/icon-switch.tsx | 97 +++++++++++++++ packages/emcn/src/components/index.ts | 1 + 6 files changed, 246 insertions(+), 39 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle.tsx create mode 100644 packages/emcn/src/components/icon-switch/icon-switch.test.tsx create mode 100644 packages/emcn/src/components/icon-switch/icon-switch.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle.tsx new file mode 100644 index 00000000000..d8a0d7ad94a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle.tsx @@ -0,0 +1,28 @@ +import { IconSwitch } from '@sim/emcn' +import { List } from '@sim/emcn/icons' +import { VariableIcon } from '@/components/icons' +import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility' + +interface CanonicalModeToggleProps { + mode: CanonicalMode + disabled?: boolean + onToggle?: () => void +} + +const MODE_OPTIONS = [ + { value: 'basic', label: 'Selector', icon: List }, + { value: 'advanced', label: 'Variable', icon: VariableIcon }, +] as const + +export function CanonicalModeToggle({ mode, disabled, onToggle }: CanonicalModeToggleProps) { + return ( + onToggle?.()} + disabled={disabled} + showTooltips + aria-label='Input mode' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index 921e0c15285..1eee07d2002 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -1,3 +1,4 @@ +export { CanonicalModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle' export { CheckboxList } from './checkbox-list' export { Code } from './code' export { ComboBox } from './combobox' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 2fd31809eac..b0e22a32d98 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -1,17 +1,11 @@ import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react' import { Button, cn, Input, Label, Tooltip } from '@sim/emcn' -import { - ArrowLeftRight, - ArrowUp, - Check, - Clipboard, - SquareArrowUpRight, - TriangleAlert, -} from '@sim/emcn/icons' +import { ArrowUp, Check, Clipboard, SquareArrowUpRight, TriangleAlert } from '@sim/emcn/icons' import { isEqual } from 'es-toolkit' import { useParams } from 'next/navigation' import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants' import { + CanonicalModeToggle, CheckboxList, Code, ComboBox, @@ -374,37 +368,11 @@ const renderLabel = ( )} {showCanonicalToggle && ( - - - - - -

- {canonicalToggle?.mode === 'advanced' - ? 'Switch to selector' - : 'Switch to manual ID'} -

-
-
+ )} diff --git a/packages/emcn/src/components/icon-switch/icon-switch.test.tsx b/packages/emcn/src/components/icon-switch/icon-switch.test.tsx new file mode 100644 index 00000000000..4d2c86e0e4f --- /dev/null +++ b/packages/emcn/src/components/icon-switch/icon-switch.test.tsx @@ -0,0 +1,112 @@ +/** @vitest-environment jsdom */ +import { act, useState } from 'react' +import { IconSwitch } from '@sim/emcn' +import { Code, List } from '@sim/emcn/icons' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const OPTIONS = [ + { value: 'selector', label: 'Selector', icon: List }, + { value: 'variable', label: 'Variable', icon: Code }, +] as const + +interface HarnessProps { + disabled?: boolean + showTooltips?: boolean + onValueChange: (value: string) => void +} + +function Harness({ disabled, showTooltips, onValueChange }: HarnessProps) { + const [value, setValue] = useState('selector') + return ( + { + setValue(nextValue) + onValueChange(nextValue) + }} + disabled={disabled} + showTooltips={showTooltips} + aria-label='Input mode' + /> + ) +} + +let root: Root | null = null +let container: HTMLDivElement + +beforeEach(() => { + vi.useFakeTimers() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root?.unmount()) + container.remove() + root = null + vi.useRealTimers() +}) + +function mount(props: HarnessProps) { + act(() => root?.render()) + return { + inputs: [...container.querySelectorAll('input')], + labels: [...container.querySelectorAll('label')], + } +} + +describe('IconSwitch', () => { + it('selects either mode without toggling an already selected choice', () => { + const onValueChange = vi.fn() + const { inputs, labels } = mount({ onValueChange }) + + act(() => labels[1].click()) + expect(inputs.map((input) => input.checked)).toEqual([false, true]) + expect(onValueChange).toHaveBeenLastCalledWith('variable') + + act(() => labels[1].click()) + expect(onValueChange).toHaveBeenCalledTimes(1) + + act(() => labels[0].click()) + expect(inputs.map((input) => input.checked)).toEqual([true, false]) + expect(onValueChange).toHaveBeenLastCalledWith('selector') + }) + + it('prevents selection changes while disabled', () => { + const onValueChange = vi.fn() + const { inputs, labels } = mount({ disabled: true, onValueChange }) + + act(() => labels[1].click()) + expect(onValueChange).not.toHaveBeenCalled() + expect(inputs.every((input) => input.disabled)).toBe(true) + expect(inputs.map((input) => input.checked)).toEqual([true, false]) + }) + + it('shows each option label in its hover tooltip', () => { + const { inputs } = mount({ showTooltips: true, onValueChange: vi.fn() }) + + for (const [index, option] of OPTIONS.entries()) { + act(() => { + inputs[index].dispatchEvent( + new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }) + ) + }) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(option.label) + act(() => inputs[index].dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))) + } + }) + + it('shows a tooltip on keyboard focus without changing selection', () => { + const onValueChange = vi.fn() + const { inputs } = mount({ showTooltips: true, onValueChange }) + + act(() => inputs[1].focus()) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Variable') + expect(onValueChange).not.toHaveBeenCalled() + expect(inputs.map((input) => input.checked)).toEqual([true, false]) + }) +}) diff --git a/packages/emcn/src/components/icon-switch/icon-switch.tsx b/packages/emcn/src/components/icon-switch/icon-switch.tsx new file mode 100644 index 00000000000..b0a246ffef3 --- /dev/null +++ b/packages/emcn/src/components/icon-switch/icon-switch.tsx @@ -0,0 +1,97 @@ +'use client' + +import { type ComponentType, useId } from 'react' +import { cn } from '../../lib/cn' +import { Tooltip } from '../tooltip/tooltip' + +export interface IconSwitchOption { + value: T + label: string + icon: ComponentType<{ className?: string }> +} + +export interface IconSwitchProps { + options: readonly [IconSwitchOption, IconSwitchOption] + value: T + onValueChange: (value: T) => void + disabled?: boolean + showTooltips?: boolean + 'aria-label': string + className?: string +} + +/** + * Two square icon choices inside a compact frame. Native radios provide mutually + * exclusive selection and keyboard navigation; optional tooltips use each label. + * + * @example + * + */ +export function IconSwitch({ + options, + value, + onValueChange, + disabled = false, + showTooltips = false, + 'aria-label': ariaLabel, + className, +}: IconSwitchProps) { + const groupName = useId() + + return ( +
+ {options.map((option) => { + const Icon = option.icon + const selected = option.value === value + const optionId = `${groupName}-${option.value}` + const input = ( + onValueChange(option.value)} + disabled={disabled} + aria-label={option.label} + className='peer m-0 size-5 cursor-pointer appearance-none rounded-[3px] bg-transparent transition-colors checked:bg-[var(--surface-active)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--text-icon)] disabled:cursor-not-allowed' + /> + ) + + return ( + + ) + })} +
+ ) +} diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 039102ac8c2..5455a469bf5 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -132,6 +132,7 @@ export { } from './dropdown-menu/dropdown-menu' export { Expandable, ExpandableContent } from './expandable/expandable' export { DashedDividerLine, FieldDivider } from './field-divider/field-divider' +export { IconSwitch, type IconSwitchOption, type IconSwitchProps } from './icon-switch/icon-switch' export { Info } from './info/info' export { InfoCard, From b3851318b2f6d978e89e64c9ca9884e4a91de8c7 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:08:21 -0700 Subject: [PATCH 07/14] revert(editor): restore the canonical arrow toggle (#7550) Revert a0f38dbc783a52682b8c5f0c85e33a02a8fb15f7 because the new icon switch is not ready to ship. Keep the restoration and remaining mode-toggle migrations in a separate follow-up. --- .../components/canonical-mode-toggle.tsx | 28 ----- .../components/sub-block/components/index.ts | 1 - .../editor/components/sub-block/sub-block.tsx | 46 +++++-- .../icon-switch/icon-switch.test.tsx | 112 ------------------ .../components/icon-switch/icon-switch.tsx | 97 --------------- packages/emcn/src/components/index.ts | 1 - 6 files changed, 39 insertions(+), 246 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle.tsx delete mode 100644 packages/emcn/src/components/icon-switch/icon-switch.test.tsx delete mode 100644 packages/emcn/src/components/icon-switch/icon-switch.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle.tsx deleted file mode 100644 index d8a0d7ad94a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { IconSwitch } from '@sim/emcn' -import { List } from '@sim/emcn/icons' -import { VariableIcon } from '@/components/icons' -import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility' - -interface CanonicalModeToggleProps { - mode: CanonicalMode - disabled?: boolean - onToggle?: () => void -} - -const MODE_OPTIONS = [ - { value: 'basic', label: 'Selector', icon: List }, - { value: 'advanced', label: 'Variable', icon: VariableIcon }, -] as const - -export function CanonicalModeToggle({ mode, disabled, onToggle }: CanonicalModeToggleProps) { - return ( - onToggle?.()} - disabled={disabled} - showTooltips - aria-label='Input mode' - /> - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index 1eee07d2002..921e0c15285 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -1,4 +1,3 @@ -export { CanonicalModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle' export { CheckboxList } from './checkbox-list' export { Code } from './code' export { ComboBox } from './combobox' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index b0e22a32d98..2fd31809eac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -1,11 +1,17 @@ import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react' import { Button, cn, Input, Label, Tooltip } from '@sim/emcn' -import { ArrowUp, Check, Clipboard, SquareArrowUpRight, TriangleAlert } from '@sim/emcn/icons' +import { + ArrowLeftRight, + ArrowUp, + Check, + Clipboard, + SquareArrowUpRight, + TriangleAlert, +} from '@sim/emcn/icons' import { isEqual } from 'es-toolkit' import { useParams } from 'next/navigation' import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants' import { - CanonicalModeToggle, CheckboxList, Code, ComboBox, @@ -368,11 +374,37 @@ const renderLabel = ( )} {showCanonicalToggle && ( - + + + + + +

+ {canonicalToggle?.mode === 'advanced' + ? 'Switch to selector' + : 'Switch to manual ID'} +

+
+
)} diff --git a/packages/emcn/src/components/icon-switch/icon-switch.test.tsx b/packages/emcn/src/components/icon-switch/icon-switch.test.tsx deleted file mode 100644 index 4d2c86e0e4f..00000000000 --- a/packages/emcn/src/components/icon-switch/icon-switch.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/** @vitest-environment jsdom */ -import { act, useState } from 'react' -import { IconSwitch } from '@sim/emcn' -import { Code, List } from '@sim/emcn/icons' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const OPTIONS = [ - { value: 'selector', label: 'Selector', icon: List }, - { value: 'variable', label: 'Variable', icon: Code }, -] as const - -interface HarnessProps { - disabled?: boolean - showTooltips?: boolean - onValueChange: (value: string) => void -} - -function Harness({ disabled, showTooltips, onValueChange }: HarnessProps) { - const [value, setValue] = useState('selector') - return ( - { - setValue(nextValue) - onValueChange(nextValue) - }} - disabled={disabled} - showTooltips={showTooltips} - aria-label='Input mode' - /> - ) -} - -let root: Root | null = null -let container: HTMLDivElement - -beforeEach(() => { - vi.useFakeTimers() - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => root?.unmount()) - container.remove() - root = null - vi.useRealTimers() -}) - -function mount(props: HarnessProps) { - act(() => root?.render()) - return { - inputs: [...container.querySelectorAll('input')], - labels: [...container.querySelectorAll('label')], - } -} - -describe('IconSwitch', () => { - it('selects either mode without toggling an already selected choice', () => { - const onValueChange = vi.fn() - const { inputs, labels } = mount({ onValueChange }) - - act(() => labels[1].click()) - expect(inputs.map((input) => input.checked)).toEqual([false, true]) - expect(onValueChange).toHaveBeenLastCalledWith('variable') - - act(() => labels[1].click()) - expect(onValueChange).toHaveBeenCalledTimes(1) - - act(() => labels[0].click()) - expect(inputs.map((input) => input.checked)).toEqual([true, false]) - expect(onValueChange).toHaveBeenLastCalledWith('selector') - }) - - it('prevents selection changes while disabled', () => { - const onValueChange = vi.fn() - const { inputs, labels } = mount({ disabled: true, onValueChange }) - - act(() => labels[1].click()) - expect(onValueChange).not.toHaveBeenCalled() - expect(inputs.every((input) => input.disabled)).toBe(true) - expect(inputs.map((input) => input.checked)).toEqual([true, false]) - }) - - it('shows each option label in its hover tooltip', () => { - const { inputs } = mount({ showTooltips: true, onValueChange: vi.fn() }) - - for (const [index, option] of OPTIONS.entries()) { - act(() => { - inputs[index].dispatchEvent( - new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }) - ) - }) - expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(option.label) - act(() => inputs[index].dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))) - } - }) - - it('shows a tooltip on keyboard focus without changing selection', () => { - const onValueChange = vi.fn() - const { inputs } = mount({ showTooltips: true, onValueChange }) - - act(() => inputs[1].focus()) - expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Variable') - expect(onValueChange).not.toHaveBeenCalled() - expect(inputs.map((input) => input.checked)).toEqual([true, false]) - }) -}) diff --git a/packages/emcn/src/components/icon-switch/icon-switch.tsx b/packages/emcn/src/components/icon-switch/icon-switch.tsx deleted file mode 100644 index b0a246ffef3..00000000000 --- a/packages/emcn/src/components/icon-switch/icon-switch.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'use client' - -import { type ComponentType, useId } from 'react' -import { cn } from '../../lib/cn' -import { Tooltip } from '../tooltip/tooltip' - -export interface IconSwitchOption { - value: T - label: string - icon: ComponentType<{ className?: string }> -} - -export interface IconSwitchProps { - options: readonly [IconSwitchOption, IconSwitchOption] - value: T - onValueChange: (value: T) => void - disabled?: boolean - showTooltips?: boolean - 'aria-label': string - className?: string -} - -/** - * Two square icon choices inside a compact frame. Native radios provide mutually - * exclusive selection and keyboard navigation; optional tooltips use each label. - * - * @example - * - */ -export function IconSwitch({ - options, - value, - onValueChange, - disabled = false, - showTooltips = false, - 'aria-label': ariaLabel, - className, -}: IconSwitchProps) { - const groupName = useId() - - return ( -
- {options.map((option) => { - const Icon = option.icon - const selected = option.value === value - const optionId = `${groupName}-${option.value}` - const input = ( - onValueChange(option.value)} - disabled={disabled} - aria-label={option.label} - className='peer m-0 size-5 cursor-pointer appearance-none rounded-[3px] bg-transparent transition-colors checked:bg-[var(--surface-active)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--text-icon)] disabled:cursor-not-allowed' - /> - ) - - return ( - - ) - })} -
- ) -} diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 5455a469bf5..039102ac8c2 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -132,7 +132,6 @@ export { } from './dropdown-menu/dropdown-menu' export { Expandable, ExpandableContent } from './expandable/expandable' export { DashedDividerLine, FieldDivider } from './field-divider/field-divider' -export { IconSwitch, type IconSwitchOption, type IconSwitchProps } from './icon-switch/icon-switch' export { Info } from './info/info' export { InfoCard, From 35f75a6f75382488955d28f4936280ddf406b43e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 6 Sep 2026 02:33:48 -0700 Subject: [PATCH 08/14] docs(library): update what-is-retrieval-augmented-generation (#7557) Co-authored-by: Sim Pi Agent --- .../index.mdx | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) 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). From d3de2aa8014bbcec7481f7234a96ed7d2fbbda95 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 6 Sep 2026 02:45:22 -0700 Subject: [PATCH 09/14] feat(browser): add verified form filling and horizontal scroll (#7556) * feat(browser): add verified form filling and horizontal scroll * fix(browser): align form schemas and activity titles * fix(browser): detect truncated popup and dialog observations --- apps/desktop/e2e/browser-tools.spec.ts | 244 +++++++++++++ .../src/main/browser-agent/driver.test.ts | 322 +++++++++++++++++- apps/desktop/src/main/browser-agent/driver.ts | 260 +++++++++++++- .../main/browser-agent/form-fields.test.ts | 102 ++++++ .../main/browser-agent/page-functions.test.ts | 194 +++++++++++ .../src/main/browser-agent/page-functions.ts | 268 +++++++++++---- .../lib/copilot/generated/tool-catalog-v1.ts | 181 +++++++++- .../lib/copilot/generated/tool-schemas-v1.ts | 204 ++++++++++- .../client/browser-tool-execution.test.ts | 25 ++ .../tools/client/browser-tool-execution.ts | 13 +- .../tools/server/generated-schema.test.ts | 39 +++ .../lib/copilot/tools/tool-display.test.ts | 6 + apps/sim/lib/copilot/tools/tool-display.ts | 2 + packages/browser-protocol/src/index.ts | 1 + 14 files changed, 1778 insertions(+), 83 deletions(-) create mode 100644 apps/desktop/e2e/browser-tools.spec.ts create mode 100644 apps/desktop/src/main/browser-agent/form-fields.test.ts 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/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/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/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/packages/browser-protocol/src/index.ts b/packages/browser-protocol/src/index.ts index d7677fcce9f..5096378a687 100644 --- a/packages/browser-protocol/src/index.ts +++ b/packages/browser-protocol/src/index.ts @@ -41,6 +41,7 @@ export const CURRENT_BROWSER_TOOL_NAMES = [ 'browser_click', 'browser_click_at', 'browser_type', + 'browser_fill_form', 'browser_insert_text', 'browser_press_key', 'browser_scroll', From 004951465e243ca5858f6848206206da7d27b8e5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 6 Sep 2026 03:00:10 -0700 Subject: [PATCH 10/14] fix(quickbooks): align the integration with Intuit's published API models (#7555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quickbooks): harden the app-level webhook ingress Three defects on the QuickBooks CloudEvents ingress: - The pre-ack path loaded and decrypted every account connected to the addressed Intuit app before checking the signature. Verifier tokens are now produced by an async generator and consumed one at a time, stopping at the first match, so a legitimate delivery no longer burns the whole app's fan-out inside Intuit's 3-second acknowledgement budget. - One unmodelled element rejected the entire delivery with 400. Intuit retries a 400 indefinitely and withholds later events until one is acknowledged, so a single bad payload stopped webhooks for every Sim workspace on that Intuit app. The array shape is still bounded, but elements are parsed individually, unparseable ones are dropped with a warning, and the delivery is acknowledged with 200. - formatInput emitted the lowercase wire token ("invoice") as entityType. It now resolves the trigger definition from the parsed entity and emits the canonical QuickBooks name ("Invoice") the read tools expect; eventType still carries the raw wire string. Also count an unroutable company id as ignored rather than failed, so a permanently impossible event no longer retries three times. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): stop item full updates from corrupting inventory and transaction history Two of these silently rewrite a customer's accounting records. An Item full update echoes the record read back from QuickBooks. Intuit documents InvStartDate asymmetrically: "For read operations, the date returned in this field is always the originally provided inventory start date. For update operations, the date supplied is interpreted as the inventory adjust date, is stored as such in the underlying data model, and is reflected in the QuickBooks Online UI." QtyOnHand is re-asserted the same way. Both are "Required for Inventory type items", so neither can simply be dropped from the body — refuse the update instead, matching create_item's existing Service/NonInventory restriction. Intuit also documents inactivation as "Not valid for Category item types", so an Active change on a Category is refused too. The Item update also posted to a bare endpoint. Intuit: "Add the query parameter, include=donotupdateaccountontxns, to the endpoint to supress updating the income or expense account on any existing transactions associated with this Item object." Without it, changing an item's account rewrote every historical transaction linked to it. The parameter is documented on the Item update alone, so it is not applied to any other entity. Also aligns the shared plumbing with the documented model: - PhysicalAddress documents Line1-Line5; the write map carried only Line1/Line2, so an address Sim had just read could not be written back. - Fault.type (ValidationFault / SystemFault / AuthenticationFault / AuthorizationFault) was discarded, hiding the classification that separates a bad payload from a dead token. Matched by prefix because Intuit's pages disagree between "ValidationFault" and type="Validation". - The query string used the form-encoded "+" for spaces; Intuit's own example percent-encodes them. - MAXRESULTS was capped at 100 against a documented maximum of 1,000. - Validates documented constraints locally: DisplayName <=500, Item.Name <=100 with no tabs, new lines, or colons, and an email address Intuit can store. - update_item's activeStatus and update_employee's displayName now carry the Category and Payroll caveats Intuit documents. Adds the missing query-builder coverage and a full-update test that would catch feeding the sanitized record into the merge, which would null every vendor's TaxIdentifier. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): gate every internal tool operation and contract-bind the provider ops The QuickBooks internal handler returned for all twelve provider create/update tool ids above both the operation input cap and the trusted-identity check, so those gates only ever ran for the three file tool ids. Hoist both above the switch, matching the Asana handler. The same twelve operations had no boundary schema — executeToolOperationImplementation only checks the input is a non-array object before casting to the operation's param type. Author a contract per operation and route them through executeInternalJsonToolOperation, the canonical in-process path. The two file operations now parse through their contracts as well, which were previously declared but unreferenced. Also pass the transfer signal, not the caller's, when reading a failed transaction-PDF response body, so a stalled Intuit error body stays bounded by the 60s transfer deadline. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): stop persisting the Intuit identity token and share the webhook batch ceiling The OIDC identity JWT is only meaningful at connection time, where profile.accountId is already derived from it. Persisting it projected the token into the credential payload of every QuickBooks tool call, none of which read it. Gating it in token-resolution instead would break Shopify, which reads params.idToken as a shop domain fallback. Also exports QUICKBOOKS_WEBHOOK_MAX_EVENTS from the contract so the route no longer carries a second copy of the batch ceiling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): stop replace-allocations detaching non-invoice payment links buildPaymentLines returned only invoice LinkedTxn entries, and the unapplyOmittedInvoices path sent that array as the payment's entire Line collection. Intuit documents Payment Line.LinkedTxn.TxnType as one of Expense, Check, CreditCardCredit, JournalEntry, CreditMemo or Invoice, and an update as "send all the Lines that need to be present MINUS the lines that need to be removed" — so replacing invoice allocations silently detached every applied credit memo, expense, check and journal entry. Non-invoice lines are now carried forward first, in the order QuickBooks returned them, and counted against the payment total. Also aligned with Intuit's documented model: - salesreceiptrequest requires only Line, refundreceiptrequest only DepositToAccountRef and Line; neither lists CustomerRef, so customerId is optional on both receipt paths and CustomerRef is emitted only when given. - Line.Amount, SalesItemLineDetail.Qty and UnitPrice carry no positivity or non-zero constraint, so zero is accepted (finite/2-decimal/safe-range checks unchanged). - Enforce the documented maximum lengths locally: DocNumber 21, MemoRef.value 1000, Line.Description 4000. - Add void_sales_receipt, the documented salesreceipt?operation=update&include=void sparse void. - read_sales_transactions maxResults description now says 1–1000, matching validateQuickBooksPagination. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): align purchasing and accounting tools with Intuit's model Validated against Intuit's live machine-readable model files (EntityJsonObject_v1.json, CodesModelsJsonObjects_v2.json). - CurrencyRef is "Conditionally required" on all seven purchasing and accounting create models ("This must be defined if multicurrency is enabled for the company") and was never written, so every create failed on a multicurrency company. Creates now accept an ISO 4217 currencyCode. - GlobalTaxCalculation is "Conditionally required" on Bill, VendorCredit, PurchaseOrder, JournalEntry, and Deposit, and Optional on Purchase ("Not applicable to US companies; required for non-US companies"). Those six creates now accept it; JournalEntry accepts only the two values Intuit documents for it. BillPayment has no such property and is left alone. - Add void_bill_payment, the documented POST /billpayment?operation=update&include=void operation. - itembasedexpenselinedetail.Required is [] and ItemRef is Optional, so an item line no longer requires itemId. - The Deposit sparse update leaves "missing elements untouched", so depositAccountId is no longer forced on every update. - BillPayment DocNumber and APAccountRef are Optional writable fields and were unreachable; PurchaseOrder DueDate was emitted by the body builder but had no parameter. - Correct the maxResults range in the two read tools to 1-1000, matching validateQuickBooksPagination. - Record the unresolved BillPayment "Line [0..n]" / requiredFlag Required contradiction as a TSDoc note; behavior unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): correct the sparse-void TSDoc and narrow receipt customerId types The sparse guard's TSDoc claimed Intuit documents `sparse` as required to void any object. That is false for Invoice, whose void request model is `deleterequest` (Id + SyncToken, no sparse); only the `include=void` form carries it. The code was already correct; the comment would have led an editor to 'fix' void_invoice.ts into breaking it. Also narrows QuickBooksCreateSalesReceiptParams.customerId to optional so the type matches the required:false declaration, per salesreceiptrequest.Required listing only Line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): align reports and attachments with Intuit's report catalog Every capability flag is now transcribed from that report's own `*query` model in Intuit's machine-readable report catalog, which is the source the developer docs render their parameter tables from. Reports: - ap_aging_detail now advertises accountingMethod; `agedpayabledetailquery` documents `accounting_method`, so Sim was throwing on a request Intuit accepts. - ap_aging_summary now accepts customerId; `agedpayablesquery` documents `customer`. - Adds trial_balance_fr. Intuit documents one report with two endpoints — TrialBalanceFR for FR-locale companies, TrialBalance otherwise. - Adds the ten remaining documented report endpoints: AccountList, CustomerBalanceDetail, CustomerIncome, GeneralLedger, InventoryValuationDetail, InventoryValuationSummary, ClassSales, DepartmentSales, TaxSummary, VendorBalanceDetail. Each one's flags come from its own query model. - Exposes `date_macro` and `qzurl`, both documented per-report query params. `qzurl` is what populates the quick-zoom `href` links the row outputs already declare, and `date_macro` is mutually exclusive with an explicit date range. - Exposes the `employee` filter that only `profitandlossdetailquery` documents, and adds the `Employee` report-header echo Intuit's `reportheader` model lists. Attachments: - parseQuickBooksAttachableResponse takes an operation label. Reading an attachment by id reported failures as "attachment upload failed". - A dotless file name no longer reports itself as its own extension, so an unattachable `backup` is refused as extensionless rather than as "the backup file type". - Records why `.jpg` is canonicalized to image/jpeg: QuickBooks normalizes content type on ingest and `attachablerequest` has no ContentType property. Replaces the file_operations tool-wiring tests, which asserted only that `operation.input` is an identity projection, with coverage of the extension allowlist, MIME canonicalization, file-name sanitization, Attachable metadata, and fault labelling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): document-align the bill payment account check and refund receipt update Intuit's BillPaymentCheck.BankAccountRef requires "Account.AccountType set to Bank and Account.AccountSubType set to Checking", and BillPaymentCreditCard.CCAccountRef requires "AccountType set to Credit Card and AccountSubType set to CreditCard". The create-bill-payment guard only compared AccountType, so a Savings or Line-of-Credit account passed the local check and failed at Intuit. Intuit documents RefundReceipt::UPDATE "Sparse update a refund receipt", which "only elements specified in the request are updated. Missing elements are left untouched." The operation used the read-merge-write full update instead, adding a round trip and a read/write race. Invoice, Estimate and SalesReceipt already post their sparse bodies directly; RefundReceipt now matches. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): describe the refund receipt update as sparse The update path now posts a sparse body directly instead of read-merge-write, per RefundReceipt::UPDATE 'Sparse update a refund receipt'. The LLM-facing tool description still claimed a full update. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): wire the block and registry to the Wave 1-2 tool changes Registers the two Wave 2 void tools, feeds the create parameters that had no UI, and corrects the block-level and report-metadata defects, all against Intuit's published request/response and report query models. Registration - Export and register quickbooks_void_sales_receipt and quickbooks_void_bill_payment; both were reachable from no surface. - Mirror the void_customer_payment sites: operation option, canvas sentence, transaction ID / sync token / confirm conditions, tools.access, and params. Parameters that existed on tools but had no UI - currencyCode on every create whose request model marks CurrencyRef conditionally required under multicurrency. - globalTaxCalculation on the same set minus bill payment, which billpaymentresponse does not carry; JournalEntry narrows to the two values its model documents. - dueDate on create and update purchase order, apAccountId and documentNumber on create bill payment. - Report date macro, quick-zoom links, and the employee filter; the quick-zoom string is coerced in tools.config.params, never in tools.config.tool. - Report dropdown now offers all 26 documented reports. Block defects - Sales receipt and refund receipt no longer require a customer; Intuit's salesreceiptrequest and refundreceiptrequest do not list CustomerRef. - Pagination accepts the documented ceiling of 1000, not 100. - The attachment file name no longer means two opposite things: the upload override is scoped to Add Attachment in File mode like its siblings, and the saved-file name gets its own field. - Item account fields explain the locale rule instead of a bare placeholder, and the inconsistent advanced/basic pairs are aligned. Report metadata - summarize_column_by collapses to the single twelve-value list every one of the fourteen documenting models shares, Employees included. - appaid, arpaid, and group_by are gated per report: the customer and vendor balance models and inventoryvaluationdetailquery document them too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): split the dual-semantic transactionId and register the missing subblock migrations `transactionId` was one control across the three by-ID reads and all fourteen updates and voids, and `tools.config.params` republished that single stored value as the read target, `paymentId`, `billId`, `purchaseOrderId`, `journalEntryId` and the rest. Because subblock values are keyed by ID and are never cleared when the operation changes, a bill ID read under Read Purchasing Transactions survived a switch to Update Purchase Order and addressed the wrong entity while the block still validated. The read path moves to `readTransactionId` and `transactionId` keeps the mutations. Registers the operation-scoped migration for that rename plus the four fields an earlier change orphaned without one: the three `summarize_column_by` subsets that collapsed into `reportSummarizeBy`, and the download-side `attachmentFileName` that moved to `downloadAttachmentFileName`. `syncToken` is left alone: it is live only across updates and voids, never across the read/mutate boundary, and carries one value space. A stale token after an operation switch is rejected by QuickBooks rather than silently targeting the wrong entity. Regenerates the integration docs, tool metadata, and integration catalog, which were already stale on this branch from the Wave 1-3 tool description changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * fix(quickbooks): declare the bill payment and purchase order fields the contracts dropped 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. The contracts were authored before currencyCode/apAccountId/documentNumber were added to Create Bill Payment and dueDate to Update Purchase Order, so those params were dead: the block forwarded them and they never reached Intuit. Adds a parity test across all twelve contract-bound operations so a param added to a tool without its contract fails instead of silently disappearing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB * test(quickbooks): extend contract parity coverage to the file operations The download body is a discriminated union and the add-attachment body carries a superRefine, so neither exposes a flat shape - but their declared keys are still introspectable, so all three file tools are now held to the same parity rule as the twelve JSON operations. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB --------- Co-authored-by: Claude Opus 5 (1M context) --- .../content/docs/integrations/quickbooks.mdx | 211 ++++++- .../quickbooks/[appKey]/route.test.ts | 30 +- .../api/webhooks/quickbooks/[appKey]/route.ts | 46 +- .../quickbooks-webhook-ingress.test.ts | 21 + .../background/quickbooks-webhook-ingress.ts | 15 +- apps/sim/blocks/blocks/quickbooks.ts | 522 +++++++++++------- .../sim/lib/api/contracts/tools/quickbooks.ts | 354 ++++++++++++ apps/sim/lib/api/contracts/webhooks.ts | 8 +- .../complete-quickbooks-connection.test.ts | 24 + .../complete-quickbooks-connection.ts | 8 +- .../quickbooks/contract-param-parity.test.ts | 126 +++++ .../internal/quickbooks/execute-tool.test.ts | 197 +++++-- .../lib/internal/quickbooks/execute-tool.ts | 159 ++++-- .../internal/quickbooks/operations.test.ts | 28 + .../sim/lib/internal/quickbooks/operations.ts | 2 +- .../quickbooks/provider-operations.test.ts | 136 +++++ .../quickbooks/provider-operations.ts | 60 +- .../lib/webhooks/providers/quickbooks.test.ts | 71 ++- apps/sim/lib/webhooks/providers/quickbooks.ts | 49 +- .../webhooks/quickbooks-credentials.test.ts | 37 +- .../lib/webhooks/quickbooks-credentials.ts | 18 +- .../migrations/subblock-migrations.test.ts | 160 ++++++ .../migrations/subblock-migrations.ts | 52 ++ apps/sim/tools/generated/tool-ids.ts | 2 +- apps/sim/tools/generated/tool-metadata.ts | 2 +- apps/sim/tools/generated/tool-outputs.ts | 2 +- .../tools/quickbooks/accounting_utils.test.ts | 118 ++++ apps/sim/tools/quickbooks/accounting_utils.ts | 43 +- .../sim/tools/quickbooks/api_accuracy.test.ts | 125 ++++- apps/sim/tools/quickbooks/block.test.ts | 218 +++++++- apps/sim/tools/quickbooks/create_bill.ts | 14 + .../tools/quickbooks/create_bill_payment.ts | 19 + apps/sim/tools/quickbooks/create_customer.ts | 3 +- apps/sim/tools/quickbooks/create_deposit.ts | 14 + apps/sim/tools/quickbooks/create_employee.ts | 3 +- apps/sim/tools/quickbooks/create_item.ts | 5 +- .../tools/quickbooks/create_journal_entry.ts | 13 + apps/sim/tools/quickbooks/create_purchase.ts | 14 + .../tools/quickbooks/create_purchase_order.ts | 20 + .../tools/quickbooks/create_refund_receipt.ts | 9 +- .../tools/quickbooks/create_sales_receipt.ts | 6 +- apps/sim/tools/quickbooks/create_vendor.ts | 3 +- .../tools/quickbooks/create_vendor_credit.ts | 14 + apps/sim/tools/quickbooks/documents.test.ts | 166 ++++++ apps/sim/tools/quickbooks/documents_utils.ts | 32 +- apps/sim/tools/quickbooks/fault.test.ts | 33 ++ apps/sim/tools/quickbooks/fault.ts | 25 +- .../tools/quickbooks/file_operations.test.ts | 76 --- apps/sim/tools/quickbooks/full_update.test.ts | 115 +++- apps/sim/tools/quickbooks/index.ts | 2 + .../tools/quickbooks/purchasing_utils.test.ts | 109 ++++ apps/sim/tools/quickbooks/purchasing_utils.ts | 29 +- .../read_accounting_transactions.ts | 2 +- apps/sim/tools/quickbooks/read_attachments.ts | 2 +- apps/sim/tools/quickbooks/read_master_data.ts | 2 +- .../read_purchasing_transactions.ts | 2 +- .../quickbooks/read_sales_transactions.ts | 2 +- apps/sim/tools/quickbooks/report-metadata.ts | 349 ++++++++++-- apps/sim/tools/quickbooks/reports.test.ts | 188 +++++++ apps/sim/tools/quickbooks/reports.ts | 100 +++- .../tools/quickbooks/run_financial_report.ts | 20 + apps/sim/tools/quickbooks/sales_utils.test.ts | 201 +++++++ apps/sim/tools/quickbooks/sales_utils.ts | 171 ++++-- apps/sim/tools/quickbooks/types.ts | 101 +++- apps/sim/tools/quickbooks/update_deposit.ts | 4 +- apps/sim/tools/quickbooks/update_employee.ts | 3 +- apps/sim/tools/quickbooks/update_item.ts | 9 +- .../tools/quickbooks/update_purchase_order.ts | 6 + .../tools/quickbooks/update_refund_receipt.ts | 2 +- apps/sim/tools/quickbooks/utils.ts | 23 +- apps/sim/tools/quickbooks/values.ts | 169 +++++- .../quickbooks/void_bill_payment.test.ts | 40 ++ .../sim/tools/quickbooks/void_bill_payment.ts | 117 ++++ .../tools/quickbooks/void_sales_receipt.ts | 118 ++++ apps/sim/tools/registry.ts | 4 + apps/sim/triggers/quickbooks/quickbooks.ts | 7 + .../deployment-config/src/integrations.json | 16 +- 77 files changed, 4594 insertions(+), 632 deletions(-) create mode 100644 apps/sim/lib/internal/quickbooks/contract-param-parity.test.ts create mode 100644 apps/sim/lib/internal/quickbooks/provider-operations.test.ts create mode 100644 apps/sim/tools/quickbooks/accounting_utils.test.ts create mode 100644 apps/sim/tools/quickbooks/documents.test.ts delete mode 100644 apps/sim/tools/quickbooks/file_operations.test.ts create mode 100644 apps/sim/tools/quickbooks/void_bill_payment.test.ts create mode 100644 apps/sim/tools/quickbooks/void_bill_payment.ts create mode 100644 apps/sim/tools/quickbooks/void_sales_receipt.ts 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/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/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/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/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/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/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