From e4cedcb91f8ea705ccf8a003fde49cdbb5bdf221 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Mon, 27 Jul 2026 17:58:51 -0700
Subject: [PATCH 01/10] improvement(landing): lead the hero H1 with the AI
workspace keywords (#5996)
The H1 opened with "Sim is the", spending its first three words on a
brand token already carried by the title tag, the meta description, and
the hero's sr-only summary. Lead with the terms people search instead.
Relaxes the landing H1 rule in both places that asserted the brand had
to appear in the heading itself; the sr-only summary still opens by
naming Sim, so entity consistency for answer engines is unchanged.
---
.claude/rules/landing-seo-geo.md | 2 +-
apps/sim/app/(landing)/CLAUDE.md | 2 +-
apps/sim/app/(landing)/components/hero/hero.tsx | 4 ++--
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.claude/rules/landing-seo-geo.md b/.claude/rules/landing-seo-geo.md
index 3f69b3993ef..4f6ec536ad0 100644
--- a/.claude/rules/landing-seo-geo.md
+++ b/.claude/rules/landing-seo-geo.md
@@ -22,7 +22,7 @@ paths:
- **Answer-first pattern**: each section's H2 + subtitle should directly answer a user question (e.g. "What is Sim?", "How fast can I deploy?").
- **Atomic answer blocks**: each feature / template card should be independently extractable by an AI summariser.
- **Entity consistency**: always write "Sim" by name — never "the platform" or "our tool".
-- **Keyword density**: first 150 visible chars of Hero must name "Sim", "AI workspace", "AI agents".
+- **Keyword density**: first 150 visible chars of Hero must name "AI workspace" and "AI agents". "Sim" is carried by the title tag, the meta description, and the Hero `sr-only` summary — the H1 does not have to spend its opening words on the brand.
- **sr-only summaries**: Hero and Templates each have a `
` (~50 words) as an atomic product/catalog summary for AI citation.
- **Specific numbers**: prefer concrete figures ("1,000+ integrations", "15+ AI providers") over vague claims.
diff --git a/apps/sim/app/(landing)/CLAUDE.md b/apps/sim/app/(landing)/CLAUDE.md
index 5d96ffdf9ed..f6b82e94dc3 100644
--- a/apps/sim/app/(landing)/CLAUDE.md
+++ b/apps/sim/app/(landing)/CLAUDE.md
@@ -44,7 +44,7 @@ Target: Lighthouse 95+ on mobile, LCP < 2.0s, CLS < 0.05, minimal hydration cost
`page.tsx` owns the metadata (title, description, OG/Twitter, canonical, robots) - keep it the single source of truth and keep it aligned with the constitution's claim hierarchy. Beyond metadata:
-- **One `
`, in the hero, containing "Sim" and "AI workspace".** Strict hierarchy below it: H2 per section, H3 for items within a section. Never skip levels, never add a second H1.
+- **One `
`, in the hero, containing "AI workspace".** The brand carries in the title tag, the meta description, and the hero's `sr-only` summary, so the H1 itself is free to lead with the non-brand keywords people actually search ("AI workspace", "AI agents") rather than spending its first two words on "Sim is the". Whichever wording it uses, the hero's `sr-only` paragraph must still open by naming Sim. Strict hierarchy below it: H2 per section, H3 for items within a section. Never skip levels, never add a second H1.
- **Semantic landmarks**: ``, ``, `
)|',
+ 'a|b(?=c)',
+ '\\n\\n|(?<=\\.)\\s',
+ // Quantifier and class shapes
+ '[-=]{3,}',
+ '\\s*\\n\\s*\\n\\s*',
+ '(?:\\r\\n|\\n){2}',
+ '[\\s\\u00b7]+',
+]
+
+/**
+ * Documents chosen to exercise the divergences that have bitten: non-ASCII
+ * whitespace from PDF/HTML extraction, CJK, and adjacent delimiters.
+ */
+const DOCUMENTS = [
+ 'Section one\n\nSection two\n\nSection three',
+ 'One. Two. Three. Four.',
+ 'A B C D',
+ 'Heading\n\nAlpha beta.\nGamma delta.',
+ '# One\nalpha\n## Two\nbeta\n### Three',
+ 'onetwothree',
+ 'Chapter 1\nintro\nChapter 2\nmore',
+ // Non-breaking and exotic whitespace — the stored-data divergence
+ 'Section 1. Overview The agreement',
+ 'Bullet one Bullet two Bullet three',
+ 'Prix : 100 EUR. Livraison offerte.',
+ '第一章 概要 第二章 詳細',
+ 'line separator paragraph',
+ 'tab\tseparated\tvalues',
+ // Degenerate
+ '',
+ ' ',
+ '\n\n\n',
+ 'nodelimiterhere',
+ 'a,b,',
+ 'trailing delimiter.\n\n',
+ 'Heading\n\nAlpha beta.\nGamma delta.\n\nMore.',
+ 'onetwothree',
+ 'zabzabz',
+]
+
+/**
+ * Divergences that are known, documented on the API, and accepted.
+ *
+ * Keep this list short and specific — it is the honest boundary of the
+ * guarantee, so every entry needs a reason, not just a pattern.
+ */
+const KNOWN_DIVERGENCES: Array<{ pattern: string; doc: string; reason: string }> = [
+ {
+ pattern: '(?<=aa)',
+ doc: 'aaaaa',
+ reason:
+ 'Lookbehind whose body self-overlaps. Matching every position would mean restarting the scan one character past each match start, which is quadratic on a multi-megabyte document and forfeits the linear guarantee this module exists for. Delimiters that do not self-overlap — punctuation, tags, whitespace — are exact.',
+ },
+]
+
+function isKnownDivergence(pattern: string): boolean {
+ return KNOWN_DIVERGENCES.some((entry) => entry.pattern === pattern)
+}
+
+/**
+ * `LinearRegex.split` omits the trailing empty segment `RegExp` produces, and
+ * every caller filters empties anyway — so compare on the filtered forms.
+ */
+function normalize(segments: string[]): string[] {
+ return segments.filter((segment) => segment !== '')
+}
+
+describe('differential: split parity with the built-in engine', () => {
+ const cases = SPLIT_PATTERNS.flatMap((pattern) =>
+ DOCUMENTS.map((doc) => ({ pattern, doc }))
+ ).filter(({ pattern }) => !isKnownDivergence(pattern))
+
+ it(`covers ${cases.length} pattern/document pairs`, () => {
+ expect(cases.length).toBeGreaterThan(400)
+ })
+
+ it.each(SPLIT_PATTERNS.filter((pattern) => !isKnownDivergence(pattern)))(
+ 'splits %s identically to RegExp across every document',
+ (pattern) => {
+ const compiled = compile(pattern)
+ // A pattern the linear engine declines is handled by the caller (notice,
+ // literal fallback, or a thrown config error) — not a parity concern.
+ if (!compiled) return
+
+ for (const doc of DOCUMENTS) {
+ expect(
+ normalize(compiled.split(doc)),
+ `pattern ${pattern} on ${JSON.stringify(doc)}`
+ ).toEqual(normalize(doc.split(new RegExp(pattern, 'g'))))
+ }
+ }
+ )
+})
+
+describe('differential: test/find parity with the built-in engine', () => {
+ /** Grep-style patterns, where `test` and the match index are what matter. */
+ const MATCH_PATTERNS = [
+ 'timeout',
+ 'ECONNREFUSED',
+ 'status=\\d+',
+ 'status=5\\d\\d',
+ '^Agent',
+ 'agent$',
+ '(openai|anthropic)',
+ '\\bstatus\\b',
+ '\\d{4}-\\d{2}-\\d{2}',
+ '[Ee]xception',
+ 'https?://[^\\s"]+',
+ '\\$\\{[^}]*\\}',
+ 'block_\\d+.*output',
+ '\\s+$',
+ '^\\s*\\{.*\\}\\s*$',
+ 'a.*b.*c',
+ '.*',
+ '.+',
+ 'sk-[A-Za-z0-9]{20,}',
+ 'rate.?limit',
+ '\\w+@\\w+\\.\\w+',
+ '[0-9a-f]{8}-[0-9a-f]{4}',
+ '\\s',
+ '\\S+',
+ ]
+
+ const HAYSTACKS = [
+ 'Agent 1 called api.openai.com -> status=503 at 2026-01-01',
+ 'request timeout occurred after 30s',
+ 'ECONNREFUSED connecting to db',
+ 'user@example.com signed in',
+ 'sk-abcdefghijklmnopqrstuvwxyz012345',
+ 'value with non-breaking space',
+ '第一章 概要',
+ '{ "ok": true }',
+ '',
+ ' ',
+ ]
+
+ it.each(MATCH_PATTERNS)('matches %s identically to RegExp', (pattern) => {
+ const compiled = compile(pattern)
+ if (!compiled) return
+
+ const oracle = new RegExp(pattern)
+ for (const text of HAYSTACKS) {
+ const label = `pattern ${pattern} on ${JSON.stringify(text)}`
+ expect(compiled.test(text), label).toBe(oracle.test(text))
+
+ const match = oracle.exec(text)
+ expect(compiled.find(text), label).toBe(match ? match.index : -1)
+ }
+ })
+
+ it.each(MATCH_PATTERNS)('matches %s identically to RegExp when ignoring case', (pattern) => {
+ const compiled = compileLinearRegex(pattern, { ignoreCase: true })
+ if (!compiled) return
+
+ const oracle = new RegExp(pattern, 'i')
+ for (const text of HAYSTACKS) {
+ expect(compiled.test(text), `pattern ${pattern} on ${JSON.stringify(text)}`).toBe(
+ oracle.test(text)
+ )
+ }
+ })
+})
+
+describe('differential: every known divergence is still exactly as documented', () => {
+ // Pinned so the list cannot rot. If a divergence is silently fixed this
+ // fails and the entry must be deleted; if one spreads, the parity suites
+ // above fail. Either way the list stays honest about the real boundary.
+ it.each(KNOWN_DIVERGENCES)('$pattern still diverges on $doc — $reason', ({ pattern, doc }) => {
+ const compiled = compile(pattern)
+ expect(compiled).not.toBeNull()
+
+ expect(normalize(compiled?.split(doc) ?? [])).not.toEqual(
+ normalize(doc.split(new RegExp(pattern, 'g')))
+ )
+ })
+})
diff --git a/apps/sim/lib/core/security/linear-regex.test.ts b/apps/sim/lib/core/security/linear-regex.test.ts
new file mode 100644
index 00000000000..c6edab05414
--- /dev/null
+++ b/apps/sim/lib/core/security/linear-regex.test.ts
@@ -0,0 +1,200 @@
+/**
+ * @vitest-environment node
+ */
+
+import { describe, expect, it } from 'vitest'
+import {
+ compileLinearRegex,
+ compileLookaroundSplit,
+ isPlainText,
+ literalRegex,
+} from '@/lib/core/security/linear-regex'
+
+/**
+ * Patterns that take exponential time on a backtracking engine. `a*a*b` is the
+ * important one: it defeats `safe-regex2`'s star-height screen and a
+ * quantified-group screen alike, and measured 213s on JSC / 132s on V8 against
+ * the input below.
+ */
+const CATASTROPHIC = ['(a+)+$', '(a|a)*b', 'a*a*b', '(x+x+)+y', '(\\w+\\s?)*$', '^(\\d+)*$']
+
+describe('compileLinearRegex', () => {
+ it.each(CATASTROPHIC)('matches %s in linear time on adversarial input', (pattern) => {
+ const regex = compileLinearRegex(pattern)
+ expect(regex).not.toBeNull()
+
+ const adversarial = `${'a'.repeat(50000)}!`
+ const start = Date.now()
+ regex?.test(adversarial)
+ expect(Date.now() - start).toBeLessThan(2000)
+ })
+
+ it('interprets regex syntax rather than matching it literally', () => {
+ const regex = compileLinearRegex('status=\\d+')
+ expect(regex?.test('http status=503 here')).toBe(true)
+ expect(regex?.test('status=abc')).toBe(false)
+ expect(regex?.find('http status=503')).toBe(5)
+ })
+
+ it('honours ignoreCase only when asked', () => {
+ expect(compileLinearRegex('ERROR', { ignoreCase: true })?.test('an error here')).toBe(true)
+ expect(compileLinearRegex('ERROR')?.test('an error here')).toBe(false)
+ })
+
+ it('splits equivalently to String.prototype.split, minus the trailing empty', () => {
+ const doc = '# One\ntext a\n\n# Two\ntext b'
+ expect(compileLinearRegex('\\n\\n+')?.split(doc)).toEqual(doc.split(/\n\n+/g))
+
+ // The documented divergence: a trailing delimiter yields no empty tail.
+ expect(compileLinearRegex(',')?.split('a,b,')).toEqual(['a', 'b'])
+ expect('a,b,'.split(/,/g)).toEqual(['a', 'b', ''])
+ })
+
+ it.each([
+ ['non-breaking space', '\u00a0'],
+ ['narrow no-break space', '\u202f'],
+ ['ideographic space', '\u3000'],
+ ['line separator', '\u2028'],
+ ['vertical tab', '\v'],
+ ])('treats %s as whitespace, matching the built-in engine', (_label, ws) => {
+ // RE2's own \\s is ASCII-only. Untranslated, a \\s document splitter stops
+ // splitting on the whitespace that PDF/HTML extraction emits, silently
+ // changing stored chunk boundaries.
+ const doc = `alpha${ws}beta`
+ expect(compileLinearRegex('\\s+')?.split(doc)).toEqual(doc.split(/\s+/g))
+ expect(compileLinearRegex('\\s')?.test(doc)).toBe(true)
+ })
+
+ it.each([
+ ['lookahead', '(?=foo)bar'],
+ ['lookbehind', '(?<=id: )\\w+'],
+ ['backreference', '(ab)\\1'],
+ ['invalid syntax', '('],
+ ])('returns null for %s so the caller must choose how to degrade', (_label, pattern) => {
+ expect(compileLinearRegex(pattern)).toBeNull()
+ })
+
+ it('returns -1 from find when there is no match', () => {
+ expect(compileLinearRegex('zzz')?.find('abc')).toBe(-1)
+ })
+})
+
+describe('literalRegex', () => {
+ it('treats regex syntax as ordinary characters', () => {
+ const regex = literalRegex('a+b')
+ expect(regex.test('xxa+bxx')).toBe(true)
+ expect(regex.test('aaab')).toBe(false)
+ })
+
+ it('is unaffected by repeated calls (no lastIndex carry-over)', () => {
+ const regex = literalRegex('needle')
+ const text = 'needle here and needle again'
+ expect([regex.test(text), regex.test(text), regex.test(text)]).toEqual([true, true, true])
+ expect([regex.find(text), regex.find(text)]).toEqual([0, 0])
+ })
+
+ it('matches case-insensitively when asked', () => {
+ expect(literalRegex('Needle', { ignoreCase: true }).test('a NEEDLE')).toBe(true)
+ expect(literalRegex('Needle').test('a NEEDLE')).toBe(false)
+ })
+})
+
+describe('isPlainText / escapeRegExp', () => {
+ it.each(['timeout', 'ECONNREFUSED', 'status=503', 'GET /api/logs'])(
+ 'treats %s as plain text',
+ (pattern) => expect(isPlainText(pattern)).toBe(true)
+ )
+
+ it.each(['example.com', 'a+b', '^x', '(a|b)', '[abc]'])(
+ 'treats %s as containing metacharacters',
+ (pattern) => expect(isPlainText(pattern)).toBe(false)
+ )
+
+ it.each(['.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\'])(
+ 'escapes %s so a literal pattern matches only itself',
+ (meta) => {
+ const regex = literalRegex(`a${meta}b`)
+ expect(regex.test(`a${meta}b`)).toBe(true)
+ // Under-escaping shows up here: an unescaped metacharacter would let the
+ // pattern match text that does not contain it verbatim.
+ expect(regex.test('aXb')).toBe(false)
+ expect(regex.test('ab')).toBe(false)
+ }
+ )
+})
+
+describe('compileLookaroundSplit', () => {
+ it('splits before each delimiter for (?=X), matching String.split', () => {
+ const doc = '# One\nalpha\n# Two\nbeta'
+ expect(compileLookaroundSplit('(?=#\\s)')?.split(doc)).toEqual(
+ doc.split(/(?=#\s)/g).filter(Boolean)
+ )
+ })
+
+ it('splits after each delimiter for (?<=X)', () => {
+ const doc = 'onetwothree'
+ expect(compileLookaroundSplit('(?<=)')?.split(doc)).toEqual([
+ 'one',
+ 'two',
+ 'three',
+ ])
+ })
+
+ it('stays linear on a catastrophic body', () => {
+ const regex = compileLookaroundSplit('(?=a*a*b)')
+ expect(regex).not.toBeNull()
+
+ const start = Date.now()
+ regex?.split(`${'a'.repeat(20000)}!`)
+ expect(Date.now() - start).toBeLessThan(2000)
+ })
+
+ it.each([
+ ['split after a period', '(?<=\\.)\\s+', 'One. Two. Three.'],
+ ['split before a heading', '\\n(?=Chapter )', 'intro\nChapter 1\nChapter 2'],
+ ['sentence splitter', '(?<=[.!?])\\s+(?=[A-Z])', 'One. Two! Three? four.'],
+ ])('handles %s, where the assertion has an affix', (_label, pattern, doc) => {
+ // These are the common shapes: a lookaround combined with other syntax.
+ // Handling only a whole-pattern assertion would reject them outright.
+ const split = compileLookaroundSplit(pattern)?.split(doc)
+ expect(split).toEqual(doc.split(new RegExp(pattern, 'g')))
+ })
+
+ it.each([
+ ['negative lookahead', '(?!x)y'],
+ ['negative lookbehind', '(? {
+ expect(compileLookaroundSplit(pattern)).toBeNull()
+ })
+
+ it.each([
+ ['leading assertion', '(?<=\\.)\\s+|\\n\\n'],
+ ['empty middle', '(?<=)|'],
+ ['trailing assertion', 'a|b(?=c)'],
+ ])('rejects %s with top-level alternation rather than reshaping it', (_label, pattern) => {
+ // `(?<=X)A|B` means `((?<=X)A)|B`; rebuilt as `(?:X)(A|B)` it would demand
+ // the assertion before both branches. No grouping recovers that, so the
+ // only correct answer is to decline the pattern.
+ expect(compileLookaroundSplit(pattern)).toBeNull()
+ })
+
+ it('is unaffected by a capturing group inside the lookbehind', () => {
+ // The middle is captured by name, so group numbering cannot shift.
+ expect(compileLookaroundSplit('(?<=(a))b')?.split('xaby')).toEqual(['xa', 'y'])
+
+ const optional = compileLookaroundSplit('(?<=(a)|b)c')
+ expect(optional?.find('bc')).toBe(1)
+ expect(optional?.test('bc')).toBe(true)
+ })
+
+ it.each([
+ ['(?<=\\w)\\s+(?=[A-Z])', 'A B C D'],
+ ['(?<=\\w)\\s+(?=\\w)', 'a b c d e'],
+ ])('does not consume assertion text between boundaries (%s)', (pattern, doc) => {
+ // The lookahead of one boundary is the lookbehind of the next. Consuming
+ // it would swallow every other split.
+ expect(compileLookaroundSplit(pattern)?.split(doc)).toEqual(doc.split(new RegExp(pattern, 'g')))
+ })
+})
diff --git a/apps/sim/lib/core/security/linear-regex.ts b/apps/sim/lib/core/security/linear-regex.ts
new file mode 100644
index 00000000000..a22134473cb
--- /dev/null
+++ b/apps/sim/lib/core/security/linear-regex.ts
@@ -0,0 +1,367 @@
+import { RE2JS } from 're2js'
+
+/**
+ * Linear-time matching for caller-supplied regex patterns.
+ *
+ * The built-in engine backtracks, so a pattern chosen by a caller can take
+ * exponential time on input the same caller controls — `a*a*b` against a 10k
+ * run of `a` measured 213s on JSC and 132s on V8. Anywhere that runs on a
+ * shared event loop, that is a denial of service against every other tenant.
+ *
+ * Screening the pattern instead does not work. `safe-regex2` documents itself
+ * as having false negatives and passes `(a|a)*b`; rejecting quantified groups
+ * on top of it still passes `a*a*b`. Every syntactic rule only excludes the
+ * shapes someone thought to enumerate, so the engine has to change instead.
+ *
+ * RE2 has no backtracking and matches in time linear in the input. Two costs
+ * follow, and this module works to keep both off the caller:
+ *
+ * - **Syntax.** RE2 implements neither lookaround nor backreferences, and
+ * spells some escapes differently. `translateToRe2` bridges the mechanical
+ * differences and `compileLookaroundSplit` recovers the lookaround *split*
+ * idioms; genuine gaps return `null` so each caller decides how to degrade.
+ * - **Throughput.** Measured 0.5–270ms per megabyte depending on the pattern,
+ * against roughly 0.04ms/MB for the built-in engine. Callers matching a
+ * pattern with no metacharacter should take `literalRegex` instead.
+ */
+export interface LinearRegexOptions {
+ ignoreCase?: boolean
+}
+
+export interface LinearRegex {
+ /** Whether the pattern matches anywhere in `text`. */
+ test(text: string): boolean
+ /** Index of the first match in `text`, or -1. */
+ find(text: string): number
+ /**
+ * Split `text` around every match.
+ *
+ * Follows `String.prototype.split` except that a trailing empty segment is
+ * omitted — RE2 drops it, and every caller here discards empties anyway.
+ */
+ split(text: string): string[]
+}
+
+const METACHARACTERS = /[.*+?^${}()|[\]\\]/
+
+/** Escape every regex metacharacter so `input` matches only itself. */
+function escapeRegExp(input: string): string {
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+/** True when `pattern` has no metacharacter, so both engines behave identically. */
+export function isPlainText(pattern: string): boolean {
+ return !METACHARACTERS.test(pattern)
+}
+
+/**
+ * ECMAScript's `\s` as an RE2 character-class body.
+ *
+ * RE2's `\s` is ASCII-only (`[\t\n\f\r ]`), while ECMAScript's also covers
+ * `\v`, NBSP, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F, U+3000
+ * and U+FEFF. Left untranslated, a `\s`-based document splitter silently stops
+ * splitting on the non-breaking spaces that PDF, DOCX and HTML extraction put
+ * everywhere — producing different chunks, and so different embeddings, for a
+ * document that has not changed. Verified equivalent to ECMAScript `\s` across
+ * a full sweep of the BMP.
+ */
+const JS_WHITESPACE_BODY = '\\t\\n\\v\\f\\r\\x{2028}\\x{2029}\\x{feff}\\p{Zs}'
+
+/**
+ * Rewrite the mechanical ECMAScript-vs-RE2 spelling differences.
+ *
+ * Two substitutions, both verified equivalent:
+ * - `\s`/`\S` → the Unicode set above, so whitespace splitting is unchanged.
+ * - `\uXXXX` → `\x{XXXX}`, RE2's spelling. Untranslated, RE2 rejects the
+ * pattern outright, which turns the ordinary way of writing a non-ASCII
+ * delimiter (`•`) into a hard failure.
+ *
+ * Scans with escape and character-class state so `\\s` (a literal backslash
+ * followed by `s`) is left alone and `[\s\d]` splices the set body rather than
+ * nesting a class. `\S` *inside* a class is left as RE2's ASCII form: negation
+ * within a set cannot be spliced, and `[^\S\n]` is rare enough not to justify
+ * a full class parser.
+ */
+function translateToRe2(pattern: string): string {
+ let out = ''
+ let inClass = false
+ for (let i = 0; i < pattern.length; i++) {
+ const char = pattern[i]
+
+ if (char === '\\') {
+ const next = pattern[i + 1]
+ if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(pattern.slice(i + 2, i + 6))) {
+ out += `\\x{${pattern.slice(i + 2, i + 6)}}`
+ i += 5
+ continue
+ }
+ if (next === 's') {
+ out += inClass ? JS_WHITESPACE_BODY : `[${JS_WHITESPACE_BODY}]`
+ i += 1
+ continue
+ }
+ if (next === 'S' && !inClass) {
+ out += `[^${JS_WHITESPACE_BODY}]`
+ i += 1
+ continue
+ }
+ out += next === undefined ? char : char + next
+ i += 1
+ continue
+ }
+
+ if (inClass) {
+ if (char === ']') inClass = false
+ } else if (char === '[') {
+ inClass = true
+ }
+ out += char
+ }
+ return out
+}
+
+/** Index of the `)` closing the group opened at `open`, or -1. */
+function closingParen(pattern: string, open: number): number {
+ let depth = 0
+ let inClass = false
+ for (let i = open; i < pattern.length; i++) {
+ const char = pattern[i]
+ if (char === '\\') {
+ i += 1
+ continue
+ }
+ if (inClass) {
+ if (char === ']') inClass = false
+ continue
+ }
+ if (char === '[') inClass = true
+ else if (char === '(') depth += 1
+ else if (char === ')') {
+ depth -= 1
+ if (depth === 0) return i
+ }
+ }
+ return -1
+}
+
+interface SplitShape {
+ behind: string
+ middle: string
+ ahead: string
+}
+
+/**
+ * True when `pattern` has a `|` outside any group or character class.
+ *
+ * A split shape cannot be decomposed when its middle alternates at the top
+ * level: `(?<=\.)\s+|\n\n` means `((?<=\.)\s+)|(\n\n)`, so the assertion binds
+ * to the first branch only. Rebuilding it as `(?:\.)(\s+|\n\n)` would require
+ * the period before *either* branch — a silently different pattern. No grouping
+ * fixes that, so such patterns are rejected rather than reshaped.
+ */
+function hasTopLevelAlternation(pattern: string): boolean {
+ let depth = 0
+ let inClass = false
+ for (let i = 0; i < pattern.length; i++) {
+ const char = pattern[i]
+ if (char === '\\') {
+ i += 1
+ continue
+ }
+ if (inClass) {
+ if (char === ']') inClass = false
+ continue
+ }
+ if (char === '[') inClass = true
+ else if (char === '(') depth += 1
+ else if (char === ')') depth -= 1
+ else if (char === '|' && depth === 0) return true
+ }
+ return false
+}
+
+/**
+ * Decompose a split pattern into `(?<=behind) middle (?=ahead)`, with either
+ * assertion optional. Returns `null` when neither is present (the caller should
+ * compile normally) or when the shape is anything else.
+ */
+function parseSplitShape(pattern: string): SplitShape | null {
+ let rest = pattern
+ let behind = ''
+
+ if (rest.startsWith('(?<=')) {
+ const close = closingParen(rest, 0)
+ if (close === -1) return null
+ behind = rest.slice(4, close)
+ rest = rest.slice(close + 1)
+ }
+
+ let ahead = ''
+ let depth = 0
+ let inClass = false
+ for (let i = 0; i < rest.length; i++) {
+ const char = rest[i]
+ if (char === '\\') {
+ i += 1
+ continue
+ }
+ if (inClass) {
+ if (char === ']') inClass = false
+ continue
+ }
+ if (char === '[') {
+ inClass = true
+ continue
+ }
+ if (char === ')') depth -= 1
+ if (char !== '(') continue
+ depth += 1
+ if (depth !== 1 || !rest.startsWith('(?=', i)) continue
+ const close = closingParen(rest, i)
+ if (close !== rest.length - 1) continue
+ ahead = rest.slice(i + 3, close)
+ rest = rest.slice(0, i)
+ break
+ }
+
+ if (!behind && !ahead) return null
+ if (hasTopLevelAlternation(rest)) return null
+ return { behind, middle: rest, ahead }
+}
+
+/**
+ * Compile a lookaround *split* pattern onto RE2, which has no lookaround.
+ *
+ * Splitting never needs the assertion itself — only the span the delimiter
+ * consumes. `(?<=X)Y(?=Z)` becomes `(?:X)(Y)(?:Z)`, and the span of group 1 is
+ * exactly what `String.prototype.split` would remove. That covers every
+ * combination in one rule: `(?=Z)` alone splits before a delimiter and keeps
+ * it, `(?<=X)` alone splits after one, and `(?<=[.!?])\s+(?=[A-Z])` — the
+ * sentence splitter — consumes the whitespace between them.
+ *
+ * Returns `null` for negative lookaround, backreferences, a middle that
+ * alternates at the top level, or any body RE2 cannot represent.
+ *
+ * Two details make the reconstruction faithful rather than approximate. The
+ * middle is captured by *name*, so a capturing group inside `behind` cannot
+ * shift the index out from under it. And each iteration resumes the search at
+ * the end of the previous delimiter rather than at the end of the whole match,
+ * so the assertion text is not consumed — without that, every boundary whose
+ * lookahead text doubles as the next boundary's lookbehind would be swallowed
+ * (`(?<=\w)\s+(?=[A-Z])` over `A B C D` would split only half the gaps).
+ *
+ * Known divergence: a delimiter that self-overlaps — `(?<=aa)` over `aaaaa`,
+ * or a single-character middle whose matches abut as in `(?<=\w).(?=\w)` —
+ * yields fewer boundaries than the built-in engine. Matching every position
+ * would mean restarting the scan one character past each match start, which is
+ * quadratic on a multi-megabyte document and forfeits the linear guarantee
+ * this module exists for. Delimiters that do not self-overlap — punctuation,
+ * tags, whitespace between tokens — are exact, and
+ * `linear-regex.differential.test.ts` pins that.
+ */
+export function compileLookaroundSplit(
+ pattern: string,
+ options: LinearRegexOptions = {}
+): LinearRegex | null {
+ const shape = parseSplitShape(pattern)
+ if (!shape) return null
+
+ const behind = shape.behind ? `(?:${shape.behind})` : ''
+ const ahead = shape.ahead ? `(?:${shape.ahead})` : ''
+ const source = translateToRe2(`${behind}(?P${shape.middle})${ahead}`)
+
+ let compiled: ReturnType
+ try {
+ compiled = RE2JS.compile(source, options.ignoreCase ? RE2JS.CASE_INSENSITIVE : 0)
+ } catch {
+ return null
+ }
+
+ /** Span of the delimiter itself — what `String.prototype.split` removes. */
+ const delimiterAt = (text: string, from: number): { start: number; end: number } | null => {
+ const matcher = compiled.matcher(text)
+ if (!matcher.find(from)) return null
+ return { start: matcher.start('mid'), end: matcher.end('mid') }
+ }
+
+ return {
+ test: (text) => compiled.matcher(text).find(),
+ find: (text) => delimiterAt(text, 0)?.start ?? -1,
+ split: (text) => {
+ const segments: string[] = []
+ let cursor = 0
+ let searchFrom = 0
+ while (searchFrom <= text.length) {
+ const span = delimiterAt(text, searchFrom)
+ if (!span) break
+ // Always advance, so a zero-width delimiter cannot spin.
+ searchFrom = span.end > span.start ? span.end : span.start + 1
+ // Skip a boundary behind the cursor, or one that would only emit an
+ // empty leading or trailing segment.
+ if (span.start < cursor || span.start >= text.length) continue
+ if (span.start === cursor && span.end === cursor) continue
+ segments.push(text.slice(cursor, span.start))
+ cursor = span.end
+ }
+ segments.push(text.slice(cursor))
+ return segments
+ },
+ }
+}
+
+/**
+ * Match `pattern` as an escaped literal on the built-in engine.
+ *
+ * Safe because an escaped literal cannot backtrack, and far quicker than RE2 —
+ * worth taking whenever the pattern has no metacharacter to interpret, or as a
+ * degradation path when RE2 rejects the syntax.
+ */
+export function literalRegex(pattern: string, options: LinearRegexOptions = {}): LinearRegex {
+ const source = escapeRegExp(pattern)
+ const caseFlag = options.ignoreCase ? 'i' : ''
+ // Non-global, so `exec`/`test` keep no `lastIndex` between calls and one
+ // instance is reusable — callers scan line-by-line, and recompiling per line
+ // would dominate the cost.
+ const scanner = new RegExp(source, caseFlag)
+ return {
+ test: (text) => scanner.test(text),
+ find: (text) => {
+ const match = scanner.exec(text)
+ return match ? match.index : -1
+ },
+ // Built lazily: no caller splits on a literal, so the second compile is
+ // only paid if one ever does.
+ split: (text) => text.split(new RegExp(source, `g${caseFlag}`)),
+ }
+}
+
+/**
+ * Compile `pattern` into a matcher that cannot backtrack.
+ *
+ * Returns `null` when RE2 cannot represent the pattern — invalid syntax, or
+ * constructs RE2 does not implement (lookaround, backreferences, repeat counts
+ * above 1000). Callers must handle `null` explicitly rather than silently
+ * falling back to the built-in engine, which would reintroduce the exposure
+ * this exists to remove.
+ */
+export function compileLinearRegex(
+ pattern: string,
+ options: LinearRegexOptions = {}
+): LinearRegex | null {
+ try {
+ const compiled = RE2JS.compile(
+ translateToRe2(pattern),
+ options.ignoreCase ? RE2JS.CASE_INSENSITIVE : 0
+ )
+ return {
+ test: (text) => compiled.matcher(text).find(),
+ find: (text) => {
+ const matcher = compiled.matcher(text)
+ return matcher.find() ? matcher.start() : -1
+ },
+ split: (text) => compiled.split(text),
+ }
+ } catch {
+ return null
+ }
+}
diff --git a/apps/sim/lib/guardrails/validate_regex.test.ts b/apps/sim/lib/guardrails/validate_regex.test.ts
new file mode 100644
index 00000000000..905b869edb2
--- /dev/null
+++ b/apps/sim/lib/guardrails/validate_regex.test.ts
@@ -0,0 +1,67 @@
+/**
+ * @vitest-environment node
+ */
+
+import { describe, expect, it } from 'vitest'
+import { validateRegex, validateRegexPattern } from '@/lib/guardrails/validate_regex'
+
+describe('validateRegex', () => {
+ it('passes when the input matches', () => {
+ expect(validateRegex('order 12345 shipped', '\\d{5}')).toEqual({ passed: true })
+ })
+
+ it('fails with a reason when the input does not match', () => {
+ expect(validateRegex('no digits', '\\d{5}')).toEqual({
+ passed: false,
+ error: 'Input does not match regex pattern',
+ })
+ })
+
+ it('runs a catastrophic pattern in linear time', () => {
+ // Both the guardrail pattern and the text it checks are caller-influenced,
+ // and this executes on the shared event loop. `a*a*b` against this input
+ // measured 213s on JSC before the engine change.
+ const start = Date.now()
+ const result = validateRegex(`${'a'.repeat(10000)}!`, 'a*a*b')
+
+ expect(Date.now() - start).toBeLessThan(2000)
+ expect(result.passed).toBe(false)
+ })
+
+ it('reports syntax RE2 cannot evaluate rather than running it', () => {
+ const result = validateRegex('anything', '(?=foo)bar')
+ expect(result.passed).toBe(false)
+ expect(result.error).toContain('lookahead')
+ })
+
+ it('reports invalid syntax distinctly from unsupported syntax', () => {
+ const result = validateRegex('anything', '(')
+ expect(result.passed).toBe(false)
+ expect(result.error).toContain('Invalid regex pattern')
+ })
+})
+
+describe('validateRegexPattern', () => {
+ it('accepts a valid pattern', () => {
+ expect(validateRegexPattern('\\d{3}-\\d{4}')).toEqual({ valid: true })
+ })
+
+ it('rejects an empty pattern', () => {
+ expect(validateRegexPattern('')).toMatchObject({ valid: false })
+ })
+
+ it('rejects invalid syntax', () => {
+ expect(validateRegexPattern('(')).toMatchObject({ valid: false })
+ })
+
+ it.each([
+ ['lookbehind', '(?<=id: )\\w+'],
+ ['optional group', '(?:https?://)?example\\.com'],
+ ['nested quantifier', '(a+)+$'],
+ ])('accepts %s, which the removed safe-regex2 screen rejected', (_label, pattern) => {
+ // These are valid Presidio patterns that could not be saved before. The
+ // screen that blocked them caught no real ReDoS (it passes `a*a*b`), and
+ // these run in Presidio rather than in this process.
+ expect(validateRegexPattern(pattern)).toEqual({ valid: true })
+ })
+})
diff --git a/apps/sim/lib/guardrails/validate_regex.ts b/apps/sim/lib/guardrails/validate_regex.ts
index 442daa7b3ee..f8a90dc6e5e 100644
--- a/apps/sim/lib/guardrails/validate_regex.ts
+++ b/apps/sim/lib/guardrails/validate_regex.ts
@@ -1,4 +1,8 @@
-import safe from 'safe-regex2'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { compileLinearRegex } from '@/lib/core/security/linear-regex'
+
+const logger = createLogger('ValidateRegex')
/**
* Validate if input matches regex pattern
@@ -15,9 +19,24 @@ export interface RegexPatternValidation {
}
/**
- * Validate a regex pattern's syntax and safety without matching it against input:
- * it must compile (`new RegExp`) and pass `safe-regex2`'s catastrophic-backtracking
- * screen. Shared by the custom-pattern editor UI and any pre-flight boundary check.
+ * Validate a PII custom pattern's syntax before it is persisted and handed to
+ * Presidio. Shared by the custom-pattern editor UI and the write boundary.
+ *
+ * Syntax only, deliberately. This previously also ran a `safe-regex2`
+ * catastrophic-backtracking screen, which was removed because it was pure cost:
+ * it screens star height alone and is documented as having false negatives — it
+ * passes `(a|a)*b`, and `a*a*b` defeats it and every syntactic rule of its kind
+ * — while rejecting patterns that work perfectly well, including lookbehind
+ * (`(?<=id: )\w+`) and optional groups (`(?:https?://)?example\.com`). It
+ * blocked valid rules and stopped nothing.
+ *
+ * Nor could a screen here be made sound: these patterns execute in Presidio's
+ * Python engine, which backtracks on shapes RE2 accepts, so RE2-representability
+ * says nothing about their runtime there. Presidio's own request timeout is the
+ * real bound — note it fails open on timeout, leaving PII unredacted.
+ *
+ * Anything that matches a caller-supplied pattern *in this process* must use
+ * `compileLinearRegex` from `@/lib/core/security/linear-regex` instead.
*/
export function validateRegexPattern(pattern: string): RegexPatternValidation {
if (pattern.length === 0) {
@@ -26,34 +45,42 @@ export function validateRegexPattern(pattern: string): RegexPatternValidation {
try {
new RegExp(pattern)
} catch (error) {
- return { valid: false, error: `Invalid regex: ${(error as Error).message}` }
- }
- if (!safe(pattern)) {
- return {
- valid: false,
- error: 'Pattern rejected: potentially unsafe (catastrophic backtracking)',
- }
+ return { valid: false, error: `Invalid regex: ${getErrorMessage(error)}` }
}
return { valid: true }
}
+/**
+ * Match `inputStr` against a caller-defined guardrail `pattern`.
+ *
+ * Both the pattern and the input are caller-influenced and this runs on the
+ * shared event loop, so matching goes through RE2 — a backtracking engine here
+ * lets one guardrail rule stall every other request on the instance. Patterns
+ * RE2 cannot represent (lookaround, backreferences) are reported rather than
+ * run on the built-in engine, which would reintroduce that exposure.
+ */
export function validateRegex(inputStr: string, pattern: string): ValidationResult {
- let regex: RegExp
try {
- regex = new RegExp(pattern)
- } catch (error: any) {
- return { passed: false, error: `Invalid regex pattern: ${error.message}` }
+ new RegExp(pattern)
+ } catch (error) {
+ return { passed: false, error: `Invalid regex pattern: ${getErrorMessage(error)}` }
}
- if (!safe(pattern)) {
+ const regex = compileLinearRegex(pattern)
+ if (!regex) {
+ // A rule that used lookaround worked before this became RE2-only and now
+ // fails closed, which reads to the workspace as the guardrail tripping on
+ // every input. Log it so an operator can find and rewrite the rule from
+ // logs rather than from user reports.
+ logger.warn('Guardrail regex uses syntax RE2 cannot evaluate; failing closed', { pattern })
return {
passed: false,
- error: 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)',
+ error:
+ 'Regex pattern uses syntax that cannot be evaluated safely (lookahead, lookbehind and backreferences are unsupported). Rewrite it without those constructs.',
}
}
- const match = regex.test(inputStr)
- if (match) {
+ if (regex.test(inputStr)) {
return { passed: true }
}
return { passed: false, error: 'Input does not match regex pattern' }
diff --git a/apps/sim/lib/logs/log-views.test.ts b/apps/sim/lib/logs/log-views.test.ts
index 9b31ed4feb5..de444485adc 100644
--- a/apps/sim/lib/logs/log-views.test.ts
+++ b/apps/sim/lib/logs/log-views.test.ts
@@ -32,6 +32,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({
materializeLargeValueRef: materializeLargeValueRefMock,
}))
+import { sleep } from '@sim/utils/helpers'
import type { TraceSpan } from '@/lib/logs/types'
import { grepSpans, type LogViewContext, toFull, toOverview } from './log-views'
@@ -169,10 +170,114 @@ describe('grepSpans', () => {
expect(result.matches.some((m) => m.field === 'output')).toBe(true)
})
- it('falls back to literal substring on invalid regex', async () => {
- const spans = [span({ output: { v: 'value a(b found' } })]
- const result = await grepSpans(spans, '(', ctx)
+ it.each([
+ ['character class', 'status=\\d+'],
+ ['anchor', '^Agent'],
+ ['alternation', '(openai|anthropic)'],
+ ['word boundary', '\\bstatus\\b'],
+ ['bounded quantifier', '\\d{4}-\\d{2}-\\d{2}'],
+ ['wildcard', 'called.*status'],
+ ])('interprets %s regex syntax', async (_label, pattern) => {
+ // None of these patterns occur literally in the span, so a match proves the
+ // regex was interpreted. `^Agent` anchors against the name field, the rest
+ // against output — hence the field-agnostic assertion.
+ const spans = [span({ output: { v: 'called api.openai.com -> status=503 on 2026-01-01' } })]
+ const result = await grepSpans(spans, pattern, ctx)
+ expect(result.matches.length).toBeGreaterThan(0)
+ expect(result.patternNotice).toBeUndefined()
+ })
+
+ it('falls back to a literal, with a notice, for syntax RE2 does not implement', async () => {
+ const spans = [span({ output: { v: 'id: abc and (?=x) literally here' } })]
+
+ const lookahead = await grepSpans(spans, '(?=x)', ctx)
+ expect(lookahead.matches.some((m) => m.field === 'output')).toBe(true)
+ expect(lookahead.patternNotice).toContain('RE2')
+
+ // Unbalanced paren is invalid in both engines; still degrades to a literal.
+ const invalid = await grepSpans([span({ output: { v: 'value a(b' } })], '(', ctx)
+ expect(invalid.matches.some((m) => m.field === 'output')).toBe(true)
+ expect(invalid.patternNotice).toContain('RE2')
+ })
+
+ it('takes the built-in engine for a metacharacter-free pattern', async () => {
+ const spans = [span({ output: { v: 'saw ECONNREFUSED here' } })]
+ const result = await grepSpans(spans, 'ECONNREFUSED', ctx)
expect(result.matches.some((m) => m.field === 'output')).toBe(true)
+ expect(result.patternNotice).toBeUndefined()
+ })
+
+ it.each([
+ ['nested quantifier', '(a+)+$'],
+ ['duplicate alternation, passes safe-regex2', '(a|a)*b'],
+ ['adjacent quantifiers, passes every structural screen', 'a*a*b'],
+ ])('runs a catastrophic pattern in linear time (%s)', async (_label, pattern) => {
+ // Each blocks the event loop for minutes on a backtracking engine:
+ // `a*a*b` measured 213s on JSC / 132s on V8 over a 10k-character run.
+ // RE2 has no backtracking, so these are matched normally and stay fast.
+ const spans = [span({ output: { v: `${'a'.repeat(10000)}!` } })]
+
+ const start = Date.now()
+ const result = await grepSpans(spans, pattern, ctx)
+ const elapsedMs = Date.now() - start
+
+ expect(elapsedMs).toBeLessThan(1000)
+ expect(result.truncated).toBe(false)
+ })
+
+ it('matches a long pattern literally without a length cap', async () => {
+ const pattern = `${'x'.repeat(600)}needle`
+ const spans = [span({ output: { v: pattern } })]
+ const result = await grepSpans(spans, pattern, ctx)
+ expect(result.matches.some((m) => m.field === 'output')).toBe(true)
+ })
+
+ it('stops scanning and marks truncated once the character budget is exhausted', async () => {
+ const spans = [
+ span({ id: 'a', output: { v: 'x'.repeat(400) } }),
+ span({ id: 'b', output: { v: 'needle' } }),
+ ]
+ const result = await grepSpans(spans, 'needle', ctx, { maxScannedChars: 100 })
+ expect(result.matches).toEqual([])
+ expect(result.truncated).toBe(true)
+ })
+
+ it('stops scanning and marks truncated once the match-time budget is exhausted', async () => {
+ const spans = [span({ output: { v: 'needle' } })]
+ const result = await grepSpans(spans, 'needle', ctx, { matchTimeBudgetMs: 0 })
+ expect(result.matches).toEqual([])
+ expect(result.truncated).toBe(true)
+ })
+
+ it('accumulates match time and truncates once the budget is spent', async () => {
+ // Guards the accumulation in `findTimed`: with that line removed,
+ // matchTimeMs stays 0, the budget never trips, and every span is scanned.
+ const big = 'x'.repeat(400_000)
+ const spans = [
+ span({ id: 'a', output: { v: big } }),
+ span({ id: 'b', output: { v: big } }),
+ span({ id: 'c', output: { v: `${big} needle` } }),
+ ]
+
+ const result = await grepSpans(spans, 'needle|nomatch', ctx, { matchTimeBudgetMs: 1 })
+
+ expect(result.truncated).toBe(true)
+ expect(result.matches).toEqual([])
+ })
+
+ it('does not charge blob-store I/O to the match-time budget', async () => {
+ // Each slice read sleeps well past the budget: only time spent matching
+ // counts, so a slow-but-legitimate grep must still return complete results.
+ readLargeArrayManifestSliceMock.mockImplementation(async (_m: unknown, start: number) => {
+ await sleep(30)
+ return start === 400 ? [{ v: 'found the needle here' }] : [{ v: 'nothing' }]
+ })
+ const spans = [span({ output: manifest(500) as any })]
+
+ const result = await grepSpans(spans, 'needle', ctx, { matchTimeBudgetMs: 50 })
+
+ expect(result.matches.some((m) => m.field === 'output')).toBe(true)
+ expect(result.truncated).toBe(false)
})
it('returns empty for empty traceSpans', async () => {
diff --git a/apps/sim/lib/logs/log-views.ts b/apps/sim/lib/logs/log-views.ts
index e7e4feaceee..1f679ce53b0 100644
--- a/apps/sim/lib/logs/log-views.ts
+++ b/apps/sim/lib/logs/log-views.ts
@@ -1,3 +1,4 @@
+import { compileLinearRegex, isPlainText, literalRegex } from '@/lib/core/security/linear-regex'
import {
materializeLargeArrayManifest,
readLargeArrayManifestSlice,
@@ -23,6 +24,27 @@ const DEFAULT_MAX_MATCHES = 50
const DEFAULT_MAX_SNIPPET_CHARS = 500
const DEFAULT_MAX_SLICES_SCANNED = 200
+/**
+ * Cumulative time the pattern itself may spend matching, across all spans/fields.
+ *
+ * Deliberately counts only time spent matching, not the grep's wall clock: the
+ * scan awaits blob-store reads (array slices, large-value refs) between matches,
+ * and charging that I/O to the budget would truncate slow-but-legitimate greps
+ * under load. Matching is the only part that occupies the event loop, so it is
+ * the only part worth bounding.
+ *
+ * RE2JS trades throughput for its linear-time guarantee — roughly 100x slower
+ * than the built-in engine, ~25ms per megabyte — so on a very large trace this
+ * budget is what actually caps the scan rather than a formality.
+ */
+const DEFAULT_MATCH_TIME_BUDGET_MS = 5_000
+/**
+ * Total characters a single grep may run the pattern over. Bounds the work one
+ * request can demand across every span and slice; set well above any realistic
+ * trace so normal greps never trip it.
+ */
+const DEFAULT_MAX_SCANNED_CHARS = 64 * 1024 * 1024
+
// ---------------------------------------------------------------------------
// Overview (Level 2): block tree with timing + cost, NO input/output.
// ---------------------------------------------------------------------------
@@ -171,40 +193,86 @@ export interface GrepSpanMatch {
export interface GrepSpansResult {
matches: GrepSpanMatch[]
+ /**
+ * Whether the scan stopped early — because a budget was exhausted, the slice
+ * cap was hit, or `maxMatches` was reached. It is a "there may be more" flag,
+ * not proof that trace was left unread: reaching `maxMatches` on the final
+ * match sets it even when nothing remained. Treat it as a prompt to narrow
+ * the pattern, never as a count.
+ */
truncated: boolean
+ /**
+ * Present only when the pattern used syntax RE2 does not implement and was
+ * therefore matched literally. The tool catalog cannot warn up front — it is
+ * generated from a contract in another repository — so the caller is told
+ * here rather than reading zero matches as "not present in the trace".
+ */
+ patternNotice?: string
}
export interface GrepSpansOptions {
maxMatches?: number
maxSnippetChars?: number
maxSlicesScanned?: number
+ maxScannedChars?: number
+ matchTimeBudgetMs?: number
}
interface GrepState {
matches: GrepSpanMatch[]
slicesScanned: number
+ scannedChars: number
+ matchTimeMs: number
truncated: boolean
maxMatches: number
maxSnippetChars: number
maxSlicesScanned: number
- regex: RegExp
+ maxScannedChars: number
+ matchTimeBudgetMs: number
+ find: FindMatch
}
-function escapeRegExp(input: string): string {
- return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+/** Index of the first case-insensitive match in `text`, or -1. */
+type FindMatch = (text: string) => number
+
+/**
+ * Compile a caller-supplied grep pattern into a matcher that cannot backtrack.
+ *
+ * Trace text is attacker-influenced — a workflow can emit arbitrarily long
+ * uniform runs into its own block outputs — and matching runs synchronously on
+ * the shared event loop, so a backtracking engine lets one request stall every
+ * other request on the instance. See `@/lib/core/security/linear-regex` for why
+ * the engine changed rather than the pattern being screened.
+ *
+ * A pattern with no metacharacter takes the built-in engine, which is ~100x
+ * quicker and identical in meaning when there is nothing to interpret. Syntax
+ * RE2 cannot represent degrades to a literal with a notice, so the caller knows
+ * its regex was not applied instead of reading zero matches as "not present".
+ */
+function compilePattern(pattern: string): { find: FindMatch; notice?: string } {
+ if (isPlainText(pattern)) return { find: literalRegex(pattern, { ignoreCase: true }).find }
+
+ const compiled = compileLinearRegex(pattern, { ignoreCase: true })
+ if (compiled) return { find: compiled.find }
+
+ return {
+ find: literalRegex(pattern, { ignoreCase: true }).find,
+ notice:
+ 'Pattern is not valid RE2 syntax (lookahead, lookbehind and backreferences are unsupported), so it was matched as a literal string. Rewrite it without those constructs to search by regex.',
+ }
}
-function buildRegex(pattern: string): RegExp {
+function findTimed(text: string, state: GrepState): number {
+ const started = performance.now()
try {
- return new RegExp(pattern, 'i')
- } catch {
- return new RegExp(escapeRegExp(pattern), 'i')
+ return state.find(text)
+ } finally {
+ state.matchTimeMs += performance.now() - started
}
}
-function snippetAround(text: string, regex: RegExp, maxChars: number): string {
- const m = regex.exec(text)
- const index = m ? m.index : 0
+function snippetAround(text: string, index: number, state: GrepState): string {
+ const maxChars = state.maxSnippetChars
const half = Math.floor(maxChars / 2)
const start = Math.max(0, index - half)
const end = Math.min(text.length, start + maxChars)
@@ -214,7 +282,12 @@ function snippetAround(text: string, regex: RegExp, maxChars: number): string {
}
function done(state: GrepState): boolean {
- return state.truncated || state.matches.length >= state.maxMatches
+ if (state.truncated || state.matches.length >= state.maxMatches) return true
+ if (state.matchTimeMs >= state.matchTimeBudgetMs) {
+ state.truncated = true
+ return true
+ }
+ return false
}
function recordIfMatch(
@@ -224,15 +297,19 @@ function recordIfMatch(
state: GrepState
): void {
if (done(state)) return
- state.regex.lastIndex = 0
- if (!state.regex.test(text)) return
- state.regex.lastIndex = 0
+ if (state.scannedChars + text.length > state.maxScannedChars) {
+ state.truncated = true
+ return
+ }
+ state.scannedChars += text.length
+ const index = findTimed(text, state)
+ if (index < 0) return
state.matches.push({
spanId: span.id,
blockId: span.blockId,
name: span.name,
field,
- snippet: snippetAround(text, state.regex, state.maxSnippetChars),
+ snippet: snippetAround(text, index, state),
})
if (state.matches.length >= state.maxMatches) state.truncated = true
}
@@ -305,6 +382,12 @@ function safeStringify(value: unknown): string {
* directly; large-array I/O is streamed slice-by-slice (each released before the
* next); single large refs are materialized under a byte cap (falling back to
* the ref preview). Only bounded match snippets are accumulated.
+ *
+ * `pattern` is matched by a non-backtracking engine — see `compilePattern` — so
+ * no pattern can blow up on any input. Two budgets bound total work on top of
+ * that: a character budget and a cumulative match-time budget. Neither counts
+ * the blob-store I/O this scan awaits, so a slow-but-legitimate grep is not
+ * truncated merely for being slow.
*/
export async function grepSpans(
spans: TraceSpan[],
@@ -312,14 +395,19 @@ export async function grepSpans(
ctx: LogViewContext,
opts?: GrepSpansOptions
): Promise {
+ const compiled = compilePattern(pattern)
const state: GrepState = {
matches: [],
slicesScanned: 0,
+ scannedChars: 0,
+ matchTimeMs: 0,
truncated: false,
maxMatches: opts?.maxMatches ?? DEFAULT_MAX_MATCHES,
maxSnippetChars: opts?.maxSnippetChars ?? DEFAULT_MAX_SNIPPET_CHARS,
maxSlicesScanned: opts?.maxSlicesScanned ?? DEFAULT_MAX_SLICES_SCANNED,
- regex: buildRegex(pattern),
+ maxScannedChars: opts?.maxScannedChars ?? DEFAULT_MAX_SCANNED_CHARS,
+ matchTimeBudgetMs: opts?.matchTimeBudgetMs ?? DEFAULT_MATCH_TIME_BUDGET_MS,
+ find: compiled.find,
}
const walk = async (list: TraceSpan[]): Promise => {
@@ -335,5 +423,9 @@ export async function grepSpans(
}
await walk(spans)
- return { matches: state.matches, truncated: state.truncated }
+ return {
+ matches: state.matches,
+ truncated: state.truncated,
+ ...(compiled.notice ? { patternNotice: compiled.notice } : {}),
+ }
}
diff --git a/apps/sim/package.json b/apps/sim/package.json
index b0205c85f7f..8ef25a4b95b 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -194,6 +194,7 @@
"posthog-node": "5.28.9",
"pptxgenjs": "4.0.1",
"prismjs": "^1.30.0",
+ "re2js": "2.8.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.2",
@@ -206,7 +207,6 @@
"remark-gfm": "4.0.1",
"resend": "^4.1.2",
"rss-parser": "3.13.0",
- "safe-regex2": "5.1.0",
"sharp": "0.35.3",
"socket.io-client": "4.8.1",
"ssh2": "^1.17.0",
diff --git a/bun.lock b/bun.lock
index 20cc0d013c2..597bc798df4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -269,6 +269,7 @@
"posthog-node": "5.28.9",
"pptxgenjs": "4.0.1",
"prismjs": "^1.30.0",
+ "re2js": "2.8.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.2",
@@ -281,7 +282,6 @@
"remark-gfm": "4.0.1",
"resend": "^4.1.2",
"rss-parser": "3.13.0",
- "safe-regex2": "5.1.0",
"sharp": "0.35.3",
"socket.io-client": "4.8.1",
"ssh2": "^1.17.0",
@@ -3598,6 +3598,8 @@
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
+ "re2js": ["re2js@2.8.6", "", {}, "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg=="],
+
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
@@ -3696,8 +3698,6 @@
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
- "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
-
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"retry-request": ["retry-request@7.0.2", "", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="],
@@ -3734,8 +3734,6 @@
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
- "safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="],
-
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
From 8b199d719964d58f91073edbfeddf12e216bb80a Mon Sep 17 00:00:00 2001
From: Waleed
Date: Mon, 27 Jul 2026 20:08:19 -0700
Subject: [PATCH 04/10] fix(files): serve rendered documents and stream
workspace archives (#5995)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(files): serve rendered documents instead of generator source
Generated docs (docx/pptx/pdf/xlsx) store their generation source as the primary
file; the rendered binary lives in a separate content-addressed artifact store.
resolveServableDocBytes is the chokepoint that swaps one for the other, and the
serve route, single-file download and ~50 tool routes all go through it.
Archive compression and the public v1 download read raw bytes instead, so a
generated document arrived as source text under a .docx name and Word reported
it as corrupt. Both now resolve through the servable reader.
Adds fetchServableWorkspaceFileBuffer beside the raw reader so the record to
UserFile mapping lives in one place, preserving storageContext; the raw reader's
doc comment now says it returns generation source, so the next call site has to
choose deliberately. v1 also has to send the resolved content type rather than
the record's source MIME, and returns a retryable 409 rather than a 500 when an
artifact is still compiling. docNotReadyMessage centralizes the 409 copy, and
isRenderableDocumentName is shared so the read path and its callers agree on
which extensions can expand.
* perf(files): stream workspace archives instead of buffering them
The bulk download route materialized every selected file before writing a byte,
so peak memory tracked the size of the selection. Ordinary files are now
appended as lazy streams: each opens its storage read only when the archiver
reaches it, so one entry is resident at a time rather than the whole archive.
Generated documents still resolve to buffers first. They are the only entries
whose bytes decide anything, and every status this route returns comes from
them — once the first byte is written the status code is committed, so those
decisions have to happen before the archive starts. The per-entry allowance and
byte budget therefore govern documents only.
archiver processes appended entries through a sequential queue; lazystream
defers each storage read until that entry's turn, since handing the archiver an
open stream per entry would hold more connections than the storage client pools.
nodeReadableToWebStream moves out of input-validation.server.ts into a shared
util rather than being written twice: Readable.toWeb throws ERR_INVALID_STATE
when a consumer cancels while the source is still flowing, which is exactly what
a cancelled download does.
Trade: a storage read failing mid-archive truncates the response rather than
returning 500, since the status is already sent. Documents cannot hit this. The
table export route already behaves this way.
* improvement(files): surface archive download errors in place
Bulk and folder downloads navigated to the API route, so any rejection replaced
the Files page with the raw JSON error body. Single-file download already
fetched and saved the blob, so the two paths had diverged.
That was survivable when the only failures were "too many files" and "too
large"; resolving rendered documents adds a 409 for a still-compiling artifact,
which is reachable in normal use. Both archive paths now fetch the zip and show
the server's message as a toast — the route writes that copy for a person, so it
is worth surfacing rather than discarding. Single-file download shows its error
too instead of only logging it.
* improvement(files): simplify the archive route and stop buffering real uploads
Four review passes over the branch converged on the same points.
Routing every office extension through the buffered path held an entire selection
of genuinely uploaded documents in memory — for a check the resolver settles on
the first few magic bytes. Only files stored under a generator-source content
type need resolving; a real .docx serves what is stored and now streams like
anything else, which is what the change was for.
With resolution sequential, the AbortController cancelled nothing (its signal
never reached the storage read), the success-path over-limit branch was
unreachable, and the cancellation guard in the catch could not fire. A plain loop
that returns at the point of failure replaces the outcome record, the sentinel,
the fan-out helper at limit 1, and four post-hoc scans. ZIP_MATERIALIZE_CONCURRENCY
was dead, and the aggregate over-limit message reported a byte count that was by
construction under the limit.
lazystream and its types are gone: Readable.from over an async generator defers
the open the same way and propagates source errors natively, so the PassThrough
relay went with them. The client uses requestRaw with the existing binary
contract instead of a hand-built query string and a re-typed error extractor, and
both archive call sites share one callback. The render headroom moves beside
isRenderableDocumentName, the servable reader stops minting a request id per
file, and the v1 download returns a view rather than a second full copy.
* improvement(files): keep the lazy entry stream in byte mode
Readable.from defaults to object mode; these chunks are bytes headed for an
archive, so the mode is now explicit. Also makes the fire-and-forget bulk
download call consistent with its sibling.
* improvement(files): correct the servable reader's 409 note
Batch callers build the response from docNotReadyMessage rather than through
docNotReadyResponse, so the doc comment now describes the outcome instead of
naming one of the two helpers.
* improvement(files): correct two comments that described the wrong behavior
The resolution loop's comment claimed at most one rendered document is resident.
It is not: every resolved buffer is held until the archive is assembled, bounded
by the request's remaining budget. The loop resolves one at a time, it does not
release one at a time.
RENDERED_DOCUMENT_HEADROOM_BYTES was documented as bounding the expansion beyond
the declared size, but it is passed straight through as maxBytes and is an
absolute ceiling on the rendered document — renamed to MAX_RENDERED_DOCUMENT_BYTES
so the name matches. Download failures also carry a fallback message, so a
transport error surfaces something better than a bare "Failed to fetch".
* fix(files): put streamed files and rendered documents on one byte budget
The budget only counted rendered documents, so streamed entries never reserved
their declared bytes. A mixed selection could clear the up-front declared-size
gate and still ship close to two full limits: ordinary files up to the cap, plus
documents drawing a fresh cap of their own.
Streamed entries ship exactly what they declared, so their share is known before
anything is read and is now reserved up front; documents draw from what is left.
The budget-exhausted rejection also quoted declared sizes, which for a selection
of small sources reads as a tiny total exceeding the limit. That case has no
knowable byte count — the documents have not been rendered — so it now says what
happened without inventing a number.
* fix(files): attach the download anchor and handle a finalize rejection
A detached anchor's click() works in current browsers, but every other download
helper in the app attaches first, and a silent no-op on this path would look
exactly like a download that never started. Not worth the ambiguity for two DOM
operations.
archive.finalize() was fired with void, so a failure after the response had
started became an unhandled rejection on top of the stream error event that
already fails the response. It is caught and logged now.
* fix(files): guard the archive download against concurrent clicks
Navigating to the route meant a second click just re-navigated. Fetching the
archive instead means each click starts another download that holds the whole
zip in tab memory, and the Download button gave no sign anything was happening.
The button now reflects the in-flight download alongside the existing move and
delete states. The ref guard is there as well as the state because two clicks in
the same tick would both pass a state check.
* fix(files): resolve every generated-document source type, not four of five
The archive keyed off a locally enumerated set of source content types that
omitted text/x-pdflibjs — the isolated-vm PDF generator. Those files failed the
check and streamed their generator source under a .pdf name, which is the exact
corruption this change exists to fix.
A canonical five-member set already existed in the file viewer; enumerating a
fourth copy was the mistake, not the missing string. The set moves to file-utils
beside the other file-type predicates, the viewer imports it, and the route asks
isGeneratedDocumentSourceType rather than carrying its own list. A parameterized
test pins all five, so adding a generator without extending the set fails.
* test(files): validate the produced archive with a real zip reader
Every existing test mocks the storage stream and reads the result back with the
same JS library that wrote it, which cannot catch the failure this route exists
to fix: an archive a real zip reader will not open.
This drives 5 MB of non-repeating bytes from an fs stream through archiver, the
lazy entry generator and the Node-to-web bridge, then checks the output with the
operating system's unzip and compares the extracted bytes. Skips rather than
fails where unzip is unavailable.
* fix(files): bound the markdown export's embedded-asset bundling
The embed list is produced by scanning the document body, so its length and the
bytes behind it are whatever the author put there. Every referenced asset was
downloaded at once with no concurrency bound, no per-file cap and no total cap,
so one request could materialize an unbounded number of unbounded files. Its
sibling bulk-download route already had a count cap and a byte cap.
Metadata is now resolved first, which bounds the download from declared sizes
before a byte is read and moves the authorization check off the download path.
Assets are then fetched with bounded concurrency and a per-file cap; a single
unreadable or oversized asset drops out of the bundle rather than failing the
export, which is what the previous allSettled did.
* fix(files): mount rendered documents into the sandbox, not generator source
Mounting a generated document handed the sandbox its generator source under a
.docx name, so a python-docx or openpyxl script failed on a file that looked
fine and the agent debugged code that was correct.
Both branches were wrong, not just the buffered one: with cloud storage the
mount presigns record.key, which is the raw source object, so swapping the
buffered read alone would have fixed only local storage. Source-backed documents
now always take the servable path — they are bounded by the render ceiling, so
routing them through the web process instead of presigning is affordable.
The text/binary decision also keyed off record.type, which for these files is
text/x-docxjs, so a resolved binary would have been decoded as UTF-8. It reads
the resolved content type now.
* chore(deps): regenerate the lockfile against staging's dependency rework
Staging reworked the OTel split, dropped dead deps and declared emcn peers, so
the lockfile is regenerated on top of that rather than carrying a stale merge.
The archive dependency is the only addition; lazystream stays as archiver's
transitive dep now that it is no longer declared directly.
Regenerated incrementally rather than from scratch: a clean resolve fails the
bunfig age gate because minimumReleaseAgeExcludes lists only the darwin and
linux-gnu @next/swc binaries, not the musl and windows variants that a full
re-resolve also pulls.
* fix(files): budget sandbox mounts on rendered bytes, not declared source size
A source-backed document declares the size of its generator, not of the document,
so the per-file and aggregate mount pre-checks were reading a number unrelated to
what was about to be mounted — tiny sources cleared the guard and only afterwards
was the running total updated with the real length.
Those pre-checks now apply only to files that serve what they declare. A document
is capped by the read instead, at the smaller of the per-file limit and the budget
left, and a size rejection is reported in the same terms as the other mount
limits. Same invariant the archive route already had to learn: declared size is
not a bound once bytes can be rendered.
* test(files): cover the two surfaces added without tests
The markdown export route had no test file at all, and the sandbox mount's
source-backed branch was exercised by nothing — both were changed in this PR and
one of them shipped a defect a reviewer caught within minutes.
Export: the embed-count rejection, the declared-bytes rejection landing before
any asset leaves storage, the per-asset download cap, an unreadable asset
dropping out rather than failing the export, and an unauthorized asset never
being read.
Mount: a generated document never presigning its raw key even on cloud storage,
its rendered bytes mounting as base64 rather than being utf-8 decoded off the
source MIME, and the budget rejecting on rendered length.
* fix(files): stop the export caps from rejecting documents that used to work
The caps I picked would have failed exports that previously succeeded: a
screenshot-heavy document can hold more than 100 embeds, and 100 embeds of
unoptimised PNGs can exceed 100 MB. Adding a limit to bound memory is right;
setting it below real usage turns a memory risk into a broken feature.
The byte ceiling now matches the bulk-download route, so the two export surfaces
reject at the same size rather than at two invented ones. The count is only a
guard on the metadata lookups that run before the byte check, so it moves well
above any hand-authored document instead of sitting where a legitimate one
could reach it.
---
.../app/api/files/export/[id]/route.test.ts | 158 ++++++++
apps/sim/app/api/files/export/[id]/route.ts | 99 +++--
apps/sim/app/api/tools/file/manage/route.ts | 12 +-
apps/sim/app/api/v1/files/[fileId]/route.ts | 47 ++-
.../files/download/route.integration.test.ts | 171 +++++++++
.../[id]/files/download/route.test.ts | 349 ++++++++++++++++++
.../workspaces/[id]/files/download/route.ts | 160 +++++++-
.../file-viewer/use-editable-file-content.ts | 9 +-
.../workspace/[workspaceId]/files/files.tsx | 49 ++-
.../tools/handlers/function-execute.test.ts | 59 +++
.../tools/handlers/function-execute.ts | 53 ++-
.../core/security/input-validation.server.ts | 62 +---
apps/sim/lib/core/utils/node-stream.ts | 63 ++++
apps/sim/lib/uploads/client/download.ts | 67 +++-
.../workspace/workspace-file-manager.ts | 38 +-
.../lib/uploads/utils/file-utils.server.ts | 5 +-
apps/sim/lib/uploads/utils/file-utils.ts | 38 ++
.../uploads/utils/servable-file-response.ts | 30 +-
apps/sim/package.json | 2 +
bun.lock | 102 +++--
20 files changed, 1374 insertions(+), 199 deletions(-)
create mode 100644 apps/sim/app/api/files/export/[id]/route.test.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/files/download/route.test.ts
create mode 100644 apps/sim/lib/core/utils/node-stream.ts
diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts
new file mode 100644
index 00000000000..1056fd61e05
--- /dev/null
+++ b/apps/sim/app/api/files/export/[id]/route.test.ts
@@ -0,0 +1,158 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import JSZip from 'jszip'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckAuth,
+ mockGetFileMetadataById,
+ mockVerifyFileAccess,
+ mockDownloadFile,
+ mockExtractEmbeddedImageIds,
+} = vi.hoisted(() => ({
+ mockCheckAuth: vi.fn(),
+ mockGetFileMetadataById: vi.fn(),
+ mockVerifyFileAccess: vi.fn(),
+ mockDownloadFile: vi.fn(),
+ mockExtractEmbeddedImageIds: vi.fn(),
+}))
+
+vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth }))
+vi.mock('@/lib/uploads/server/metadata', () => ({
+ getFileMetadataById: mockGetFileMetadataById,
+}))
+vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess }))
+vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
+vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({
+ extractEmbeddedImageIds: mockExtractEmbeddedImageIds,
+}))
+vi.mock('@sim/audit', () => ({
+ recordAudit: vi.fn(),
+ AuditAction: { FILE_DOWNLOADED: 'file.downloaded' },
+ AuditResourceType: { FILE: 'file' },
+}))
+vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
+
+import { GET } from '@/app/api/files/export/[id]/route'
+
+const MB = 1024 * 1024
+const DOC_ID = 'doc-1'
+const context = { params: Promise.resolve({ id: DOC_ID }) }
+
+function request() {
+ return createMockRequest('GET', undefined, {}, `http://localhost:3000/api/files/export/${DOC_ID}`)
+}
+
+function assetRecord(id: string, size: number) {
+ return {
+ id,
+ key: `workspace/ws-1/${id}`,
+ originalName: `${id}.png`,
+ contentType: 'image/png',
+ context: 'workspace',
+ size,
+ workspaceId: 'ws-1',
+ }
+}
+
+describe('markdown export bundling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' })
+ mockVerifyFileAccess.mockResolvedValue(true)
+ mockGetFileMetadataById.mockImplementation(async (id: string) =>
+ id === DOC_ID
+ ? {
+ id: DOC_ID,
+ key: 'workspace/ws-1/doc.md',
+ originalName: 'doc.md',
+ contentType: 'text/markdown',
+ context: 'workspace',
+ size: 1024,
+ workspaceId: 'ws-1',
+ }
+ : assetRecord(id, 1 * MB)
+ )
+ mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n'))
+ mockExtractEmbeddedImageIds.mockReturnValue([])
+ })
+
+ it('rejects a document embedding more assets than an export may bundle', async () => {
+ mockExtractEmbeddedImageIds.mockReturnValue(
+ Array.from({ length: 501 }, (_, index) => `img-${index}`)
+ )
+
+ const response = await GET(request(), context)
+
+ expect(response.status).toBe(400)
+ expect((await response.json()).error).toContain('501')
+ // Rejected on the embed count alone: nothing was resolved or downloaded.
+ expect(mockGetFileMetadataById).toHaveBeenCalledTimes(1)
+ })
+
+ it('rejects on declared asset bytes before downloading any of them', async () => {
+ mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c'])
+ mockGetFileMetadataById.mockImplementation(async (id: string) =>
+ id === DOC_ID
+ ? {
+ id: DOC_ID,
+ key: 'workspace/ws-1/doc.md',
+ originalName: 'doc.md',
+ contentType: 'text/markdown',
+ context: 'workspace',
+ size: 1024,
+ workspaceId: 'ws-1',
+ }
+ : assetRecord(id, 100 * MB)
+ )
+
+ const response = await GET(request(), context)
+
+ expect(response.status).toBe(400)
+ expect((await response.json()).error).toContain('exceeds')
+ // Only the markdown body was read; the 300 MB of assets never left storage.
+ expect(mockDownloadFile).toHaveBeenCalledTimes(1)
+ })
+
+ it('caps each asset download rather than trusting its declared size', async () => {
+ mockExtractEmbeddedImageIds.mockReturnValue(['a'])
+
+ await GET(request(), context)
+
+ const assetCall = mockDownloadFile.mock.calls.find(
+ ([options]) => options.key === 'workspace/ws-1/a'
+ )
+ expect(assetCall?.[0].maxBytes).toBe(25 * MB)
+ })
+
+ it('drops an unreadable asset instead of failing the whole export', async () => {
+ mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad'])
+ mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => {
+ if (key.endsWith('doc.md')) return Buffer.from('# Doc\n\n')
+ if (key.endsWith('bad')) throw new Error('storage down')
+ return Buffer.from('png-bytes')
+ })
+
+ const response = await GET(request(), context)
+
+ expect(response.status).toBe(200)
+ const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer()))
+ expect(zip.file('assets/good.png')).not.toBeNull()
+ expect(zip.file('assets/bad.png')).toBeNull()
+ })
+
+ it('skips an asset the caller cannot read', async () => {
+ mockExtractEmbeddedImageIds.mockReturnValue(['secret'])
+ mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret'))
+
+ const response = await GET(request(), context)
+
+ expect(response.status).toBe(200)
+ // Authorization is settled during metadata resolution, before any asset read.
+ expect(mockDownloadFile.mock.calls.some(([options]) => options.key.endsWith('secret'))).toBe(
+ false
+ )
+ })
+})
diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts
index 414d781c3a0..6b217f9c6c1 100644
--- a/apps/sim/app/api/files/export/[id]/route.ts
+++ b/apps/sim/app/api/files/export/[id]/route.ts
@@ -9,17 +9,33 @@ import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
+import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import type { StorageContext } from '@/lib/uploads/config'
import { getServeStoragePrefix } from '@/lib/uploads/config'
import { downloadFile } from '@/lib/uploads/core/storage-service'
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
+import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
import { encodeFilenameForHeader } from '@/app/api/files/utils'
const logger = createLogger('FilesExportAPI')
+/**
+ * Bundling caps. The embed list comes from scanning the document body, so its length
+ * and the bytes behind it are whatever the author put there — without these the export
+ * would materialize an unbounded number of unbounded assets in one request.
+ *
+ * The byte ceilings are the real bound and match the bulk-download route, so the two
+ * export surfaces reject at the same size. The count is only a guard on the metadata
+ * lookups that precede the byte check, so it sits far above any hand-authored document
+ * rather than at a number a screenshot-heavy doc could plausibly reach.
+ */
+const MAX_EXPORT_ASSETS = 500
+const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024
+const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024
+
const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown'])
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown'])
@@ -136,34 +152,73 @@ export const GET = withRouteHandler(
})
}
- const fetchResults = await Promise.allSettled(
- imageIds.map(async (imageId) => {
- const imgRecord = await getFileMetadataById(imageId)
- if (!imgRecord) return null
- const imgHasAccess = await verifyFileAccess(imgRecord.key, userId)
- if (!imgHasAccess) return null
- const imgBuffer = await downloadFile({
- key: imgRecord.key,
- context: imgRecord.context as StorageContext,
- })
- return { imageId, originalName: imgRecord.originalName, buffer: imgBuffer }
+ if (imageIds.length > MAX_EXPORT_ASSETS) {
+ return NextResponse.json(
+ {
+ error: `This document embeds ${imageIds.length} files, more than the ${MAX_EXPORT_ASSETS} an export can bundle.`,
+ },
+ { status: 400 }
+ )
+ }
+
+ // Metadata first: declared sizes bound the download before a byte is read, and the
+ // authorization check costs nothing to run here.
+ const assetTargets = (
+ await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => {
+ try {
+ const imgRecord = await getFileMetadataById(imageId)
+ if (!imgRecord) return null
+ if (!(await verifyFileAccess(imgRecord.key, userId))) return null
+ return { imageId, record: imgRecord }
+ } catch (error) {
+ logger.warn('Failed to resolve asset for export', {
+ imageId,
+ error: toError(error).message,
+ })
+ return null
+ }
})
+ ).filter((target): target is NonNullable => target !== null)
+
+ const declaredAssetBytes = assetTargets.reduce((sum, target) => sum + target.record.size, 0)
+ if (declaredAssetBytes > MAX_EXPORT_TOTAL_BYTES) {
+ return NextResponse.json(
+ {
+ error: `Embedded files total ${formatFileSize(declaredAssetBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`,
+ },
+ { status: 400 }
+ )
+ }
+
+ const fetched = await mapWithConcurrency(
+ assetTargets,
+ MATERIALIZE_CONCURRENCY,
+ async ({ imageId, record: imgRecord }) => {
+ try {
+ const buffer = await downloadFile({
+ key: imgRecord.key,
+ context: imgRecord.context as StorageContext,
+ maxBytes: MAX_EXPORT_ASSET_BYTES,
+ })
+ return { imageId, originalName: imgRecord.originalName, buffer }
+ } catch (error) {
+ // A single unreadable or oversized asset drops out of the bundle rather than
+ // failing the whole export; the markdown keeps its original link.
+ logger.warn('Failed to fetch asset for export', {
+ imageId,
+ error: toError(error).message,
+ })
+ return null
+ }
+ }
)
const assetMap = new Map()
const usedFilenames = new Set()
- for (let i = 0; i < fetchResults.length; i++) {
- const result = fetchResults[i]
- if (result.status === 'rejected') {
- logger.warn('Failed to fetch asset for export', {
- imageId: imageIds[i],
- error: toError(result.reason).message,
- })
- continue
- }
- if (!result.value) continue
- const { imageId, originalName, buffer } = result.value
+ for (const result of fetched) {
+ if (!result) continue
+ const { imageId, originalName, buffer } = result
const preferred = safeFilename(originalName)
const filename = deduplicatedFilename(preferred, usedFilenames, imageId)
usedFilenames.add(filename)
diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts
index 248002656ee..8f1cca412f6 100644
--- a/apps/sim/app/api/tools/file/manage/route.ts
+++ b/apps/sim/app/api/tools/file/manage/route.ts
@@ -11,6 +11,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { generateRequestId } from '@/lib/core/utils/request'
+import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
@@ -685,7 +686,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(userFile, requestId, logger, {
+ // Generated docs store their generation source, not the rendered binary, so
+ // the archive must carry the servable bytes instead of the raw source text.
+ // A still-compiling artifact throws, and the handler's catch turns that into
+ // the shared 409 via `docNotReadyResponse`.
+ const { buffer } = await downloadServableFileFromStorage(userFile, requestId, logger, {
maxBytes: MAX_COMPRESS_FILE_BYTES,
})
totalBytes += buffer.length
@@ -864,6 +869,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
const notReady = docNotReadyResponse(error)
if (notReady) return notReady
+ // A file over its per-file cap is a size rejection, not a fault. Rendered
+ // documents can cross it even when the stored source was well under.
+ if (isPayloadSizeLimitError(error)) {
+ return NextResponse.json({ success: false, error: error.message }, { status: 413 })
+ }
if (error instanceof ShareValidationError) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 })
}
diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts
index 258e36b9ffb..ee5c13c3509 100644
--- a/apps/sim/app/api/v1/files/[fileId]/route.ts
+++ b/apps/sim/app/api/v1/files/[fileId]/route.ts
@@ -6,7 +6,11 @@ import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
-import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
+import {
+ fetchServableWorkspaceFileBuffer,
+ getWorkspaceFile,
+} from '@/lib/uploads/contexts/workspace'
+import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response'
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import {
checkRateLimit,
@@ -48,7 +52,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
- const buffer = await fetchWorkspaceFileBuffer(fileRecord)
+ // Generated docs store their generation source; serve the rendered artifact.
+ // Its content type is the rendered one, not the source MIME on the record.
+ const { buffer, contentType } = await fetchServableWorkspaceFileBuffer(fileRecord)
recordAudit({
workspaceId,
@@ -73,21 +79,30 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
{ groups: { workspace: workspaceId } }
)
- return new Response(new Uint8Array(buffer), {
- status: 200,
- headers: {
- 'Content-Type': fileRecord.type || 'application/octet-stream',
- 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`,
- 'Content-Length': String(buffer.length),
- 'X-File-Id': fileRecord.id,
- 'X-File-Name': encodeURIComponent(fileRecord.name),
- 'X-Uploaded-At':
- fileRecord.uploadedAt instanceof Date
- ? fileRecord.uploadedAt.toISOString()
- : String(fileRecord.uploadedAt),
- },
- })
+ // View, not copy — a second full copy would double peak memory for a large file.
+ return new Response(
+ new Uint8Array(buffer.buffer as ArrayBuffer, buffer.byteOffset, buffer.byteLength),
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': contentType || fileRecord.type || 'application/octet-stream',
+ 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`,
+ 'Content-Length': String(buffer.length),
+ 'X-File-Id': fileRecord.id,
+ 'X-File-Name': encodeURIComponent(fileRecord.name),
+ 'X-Uploaded-At':
+ fileRecord.uploadedAt instanceof Date
+ ? fileRecord.uploadedAt.toISOString()
+ : String(fileRecord.uploadedAt),
+ },
+ }
+ )
} catch (error) {
+ // A generated doc whose artifact is still compiling is retryable, not a fault:
+ // without this the caller sees a 500 and has no reason to try again.
+ if (isDocNotReadyError(error)) {
+ return NextResponse.json({ error: docNotReadyMessage() }, { status: 409 })
+ }
logger.error(`[${requestId}] Error downloading file:`, error)
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
}
diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts
new file mode 100644
index 00000000000..d7a871fbcbf
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts
@@ -0,0 +1,171 @@
+/**
+ * @vitest-environment node
+ *
+ * Exercises the real archive plumbing — archiver, the lazy entry generator, and the
+ * Node-to-web bridge — against multi-chunk file streams, and validates the result with
+ * the operating system's `unzip` rather than the same JS library that produced it. The
+ * bug this route exists to fix was an archive a real zip reader could not open, so a
+ * self-consistent JS round-trip is not the assertion that matters.
+ */
+import { execFile } from 'node:child_process'
+import { createReadStream } from 'node:fs'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { promisify } from 'node:util'
+import { createMockRequest } from '@sim/testing'
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const run = promisify(execFile)
+
+const {
+ mockGetSession,
+ mockVerifyWorkspaceMembership,
+ mockListWorkspaceFiles,
+ mockListFolders,
+ mockDownloadFileStream,
+} = vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockVerifyWorkspaceMembership: vi.fn(),
+ mockListWorkspaceFiles: vi.fn(),
+ mockListFolders: vi.fn(),
+ mockDownloadFileStream: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+vi.mock('@/app/api/workflows/utils', () => ({
+ verifyWorkspaceMembership: mockVerifyWorkspaceMembership,
+}))
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ listWorkspaceFiles: mockListWorkspaceFiles,
+ listWorkspaceFileFolders: mockListFolders,
+ buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) =>
+ new Map(folders.map((folder) => [folder.id, folder.name])),
+ fetchServableWorkspaceFileBuffer: vi.fn(),
+}))
+vi.mock('@/lib/uploads/core/storage-service', () => ({
+ downloadFileStream: mockDownloadFileStream,
+}))
+vi.mock('@sim/audit', () => ({
+ recordAudit: vi.fn(),
+ AuditAction: { FILE_DOWNLOADED: 'file.downloaded' },
+ AuditResourceType: { FILE: 'file' },
+}))
+vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
+
+import { GET } from '@/app/api/workspaces/[id]/files/download/route'
+
+const WORKSPACE_ID = 'ws-1'
+const context = { params: Promise.resolve({ id: WORKSPACE_ID }) }
+
+let workDir: string
+let bigPath: string
+let bigBytes: Buffer
+/** The OS unzip is the point of this file; skip rather than fail where it is absent. */
+let hasUnzip = true
+
+beforeAll(async () => {
+ hasUnzip = await run('unzip', ['-v']).then(
+ () => true,
+ () => false
+ )
+ workDir = await mkdtemp(join(tmpdir(), 'sim-archive-'))
+ bigPath = join(workDir, 'big.bin')
+ // Several MB of non-repeating bytes: forces many 64 KiB chunks through the generator,
+ // the archiver queue and the web bridge, and would expose a truncation or ordering bug.
+ bigBytes = Buffer.alloc(5 * 1024 * 1024)
+ for (let i = 0; i < bigBytes.length; i++) bigBytes[i] = (i * 31 + (i >> 8)) & 0xff
+ await writeFile(bigPath, bigBytes)
+})
+
+afterAll(async () => {
+ await rm(workDir, { recursive: true, force: true })
+})
+
+describe('workspace files download — real archive', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
+ mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' })
+ mockListFolders.mockResolvedValue([{ id: 'folder-1', name: 'Reports', parentId: null }])
+ // A real fs stream, not a single pre-made buffer.
+ mockDownloadFileStream.mockImplementation(async () => createReadStream(bigPath))
+ })
+
+ it('produces an archive the OS unzip accepts, with byte-exact contents', async (ctx) => {
+ if (!hasUnzip) ctx.skip()
+ mockListWorkspaceFiles.mockResolvedValue([
+ {
+ id: 'f1',
+ name: 'big.bin',
+ key: `workspace/${WORKSPACE_ID}/f1`,
+ path: '/serve/f1',
+ size: bigBytes.length,
+ type: 'application/octet-stream',
+ folderId: 'folder-1',
+ },
+ ])
+
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/download?fileIds=f1`
+ ),
+ context
+ )
+ expect(response.status).toBe(200)
+
+ const zipPath = join(workDir, 'out.zip')
+ await writeFile(zipPath, Buffer.from(await response.arrayBuffer()))
+
+ // Independent validation: the CRCs and central directory have to satisfy a real
+ // zip reader, which is exactly what failed for the customer.
+ await expect(run('unzip', ['-t', zipPath])).resolves.toBeTruthy()
+
+ await run('unzip', ['-o', '-q', zipPath, '-d', join(workDir, 'out')])
+ const extracted = await readFile(join(workDir, 'out', 'Reports', 'big.bin'))
+ expect(extracted.length).toBe(bigBytes.length)
+ expect(extracted.equals(bigBytes)).toBe(true)
+ })
+
+ it('keeps entries intact and correctly named across several streamed files', async (ctx) => {
+ if (!hasUnzip) ctx.skip()
+ mockListWorkspaceFiles.mockResolvedValue(
+ ['a.bin', 'b.bin', 'c.bin'].map((name, index) => ({
+ id: `f${index}`,
+ name,
+ key: `workspace/${WORKSPACE_ID}/f${index}`,
+ path: `/serve/f${index}`,
+ size: bigBytes.length,
+ type: 'application/octet-stream',
+ folderId: 'folder-1',
+ }))
+ )
+
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/download?fileIds=f0&fileIds=f1&fileIds=f2`
+ ),
+ context
+ )
+
+ const zipPath = join(workDir, 'multi.zip')
+ await writeFile(zipPath, Buffer.from(await response.arrayBuffer()))
+ await expect(run('unzip', ['-t', zipPath])).resolves.toBeTruthy()
+
+ const { stdout } = await run('unzip', ['-l', zipPath])
+ for (const name of ['Reports/a.bin', 'Reports/b.bin', 'Reports/c.bin']) {
+ expect(stdout).toContain(name)
+ }
+ // Each entry carries the full payload — a shared or truncated stream would not.
+ expect(stdout.match(/5242880/g)).toHaveLength(3)
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts
new file mode 100644
index 00000000000..41dc0680b7b
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts
@@ -0,0 +1,349 @@
+/**
+ * @vitest-environment node
+ */
+import { Readable } from 'stream'
+import { createMockRequest } from '@sim/testing'
+import JSZip from 'jszip'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockGetSession,
+ mockVerifyWorkspaceMembership,
+ mockListWorkspaceFiles,
+ mockListWorkspaceFileFolders,
+ mockFetchServableWorkspaceFileBuffer,
+ mockDownloadFileStream,
+} = vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockVerifyWorkspaceMembership: vi.fn(),
+ mockListWorkspaceFiles: vi.fn(),
+ mockListWorkspaceFileFolders: vi.fn(),
+ mockFetchServableWorkspaceFileBuffer: vi.fn(),
+ mockDownloadFileStream: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/app/api/workflows/utils', () => ({
+ verifyWorkspaceMembership: mockVerifyWorkspaceMembership,
+}))
+
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ listWorkspaceFiles: mockListWorkspaceFiles,
+ listWorkspaceFileFolders: mockListWorkspaceFileFolders,
+ buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) =>
+ new Map(folders.map((folder) => [folder.id, folder.name])),
+ fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer,
+}))
+
+vi.mock('@/lib/uploads/core/storage-service', () => ({
+ downloadFileStream: mockDownloadFileStream,
+}))
+
+vi.mock('@sim/audit', () => ({
+ recordAudit: vi.fn(),
+ AuditAction: { FILE_DOWNLOADED: 'file.downloaded' },
+ AuditResourceType: { FILE: 'file' },
+}))
+
+vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
+
+import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile'
+import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
+import { GET } from '@/app/api/workspaces/[id]/files/download/route'
+
+const WORKSPACE_ID = 'ws-1'
+const context = { params: Promise.resolve({ id: WORKSPACE_ID }) }
+const MB = 1024 * 1024
+
+function workspaceFile(id: string, name: string, folderId: string | null = 'folder-1') {
+ return {
+ id,
+ name,
+ key: `workspace/${WORKSPACE_ID}/${id}`,
+ path: `/serve/${id}`,
+ size: 100,
+ type: 'application/octet-stream',
+ folderId,
+ }
+}
+
+/** A file whose stored bytes are a generator source, so it must be resolved. */
+function generatedDocument(id: string, name: string, folderId: string | null = 'folder-1') {
+ return { ...workspaceFile(id, name, folderId), type: 'text/x-docxjs' }
+}
+
+function requestFor(query: string) {
+ return createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/download?${query}`
+ )
+}
+
+async function zipFrom(response: Response) {
+ return JSZip.loadAsync(Buffer.from(await response.arrayBuffer()))
+}
+
+describe('workspace files download route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
+ mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' })
+ mockListWorkspaceFileFolders.mockResolvedValue([
+ { id: 'folder-1', name: 'Reports', parentId: null },
+ ])
+ mockDownloadFileStream.mockImplementation(async () => Readable.from([Buffer.from('plain')]))
+ })
+
+ it('zips the rendered bytes for a generated doc, not its stored source', async () => {
+ mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'overview.docx')])
+ // A real .docx is a ZIP; the stored source would be plain JS text.
+ const rendered = Buffer.from('PKrendered-docx')
+ mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
+ buffer: rendered,
+ contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ })
+
+ const response = await GET(requestFor('fileIds=f1'), context)
+
+ expect(response.status).toBe(200)
+ const entry = (await zipFrom(response)).file('Reports/overview.docx')
+ expect(entry).not.toBeNull()
+ expect(Buffer.from(await entry!.async('uint8array'))).toEqual(rendered)
+ })
+
+ it('streams ordinary files instead of materializing them', async () => {
+ mockListWorkspaceFiles.mockResolvedValue([workspaceFile('f1', 'clip.mp4')])
+
+ const response = await GET(requestFor('fileIds=f1'), context)
+
+ expect(response.status).toBe(200)
+ // Nothing has been read yet: the entry opens its storage read only once the
+ // consumer pulls the archive, which is what keeps peak memory to one entry.
+ expect(mockDownloadFileStream).not.toHaveBeenCalled()
+
+ const zip = await zipFrom(response)
+
+ expect(mockDownloadFileStream).toHaveBeenCalledTimes(1)
+ // Never routed through the buffering document reader.
+ expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled()
+
+ const entry = zip.file('Reports/clip.mp4')
+ expect(entry).not.toBeNull()
+ expect(await entry!.async('string')).toBe('plain')
+ })
+
+ it('preserves nested folder paths across both entry kinds', async () => {
+ mockListWorkspaceFileFolders.mockResolvedValue([
+ { id: 'folder-1', name: 'Reports', parentId: null },
+ { id: 'folder-2', name: 'visuals', parentId: 'folder-1' },
+ ])
+ mockListWorkspaceFiles.mockResolvedValue([
+ generatedDocument('f1', 'summary.docx', 'folder-1'),
+ workspaceFile('f2', 'hero.png', 'folder-2'),
+ ])
+ mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
+ buffer: Buffer.from('PKdoc'),
+ contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ })
+
+ const zip = await zipFrom(await GET(requestFor('fileIds=f1&fileIds=f2'), context))
+
+ expect(zip.file('Reports/summary.docx')).not.toBeNull()
+ expect(zip.file('visuals/hero.png')).not.toBeNull()
+ })
+
+ it('returns 409 naming the documents whose artifacts are still compiling', async () => {
+ mockListWorkspaceFiles.mockResolvedValue([
+ generatedDocument('f1', 'ready.docx'),
+ generatedDocument('f2', 'pending.docx'),
+ ])
+ mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => {
+ if (file.name === 'pending.docx')
+ throw new DocCompileUserError('Document is still being generated')
+ return { buffer: Buffer.from('PKok'), contentType: 'application/octet-stream' }
+ })
+
+ const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context)
+
+ expect(response.status).toBe(409)
+ const body = await response.json()
+ expect(body.error).toContain('pending.docx')
+ expect(body.error).not.toContain('ready.docx')
+ })
+
+ it('rejects with 400, not 500, when a document blows its own allowance', async () => {
+ mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'huge.docx')])
+ mockFetchServableWorkspaceFileBuffer.mockRejectedValue(
+ new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 })
+ )
+
+ const response = await GET(requestFor('fileIds=f1'), context)
+
+ expect(response.status).toBe(400)
+ const body = await response.json()
+ expect(body.error).toContain('huge.docx')
+ expect(body.error).not.toContain('Selected files total')
+ })
+
+ it('counts streamed files against the same budget as rendered documents', async () => {
+ // 200 MB of ordinary files leaves 50 MB of the 250 MB budget for documents.
+ mockListWorkspaceFiles.mockResolvedValue([
+ { ...workspaceFile('f1', 'clip.mp4'), size: 200 * MB },
+ generatedDocument('f2', 'report.docx'),
+ ])
+ mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
+ buffer: Buffer.from('PKdoc'),
+ contentType: 'application/octet-stream',
+ })
+
+ await zipFrom(await GET(requestFor('fileIds=f1&fileIds=f2'), context))
+
+ // Without reserving the streamed bytes the document would get the full 50 MB
+ // ceiling, letting the archive ship 250 MB of documents on top of 200 MB of video.
+ expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB)
+ })
+
+ it('rejects when streamed files leave no budget for a document', async () => {
+ mockListWorkspaceFiles.mockResolvedValue([
+ { ...workspaceFile('f1', 'clip.mp4'), size: 249 * MB },
+ generatedDocument('f2', 'report.docx'),
+ ])
+ mockFetchServableWorkspaceFileBuffer.mockRejectedValue(
+ new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 })
+ )
+
+ const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context)
+
+ expect(response.status).toBe(400)
+ const body = await response.json()
+ expect(body.error).toContain('once documents are rendered')
+ // No byte count: the rendered total is not knowable, so quoting one would mislead.
+ expect(body.error).not.toContain('Selected files total')
+ })
+
+ it('blames the shared budget once earlier documents have consumed it', async () => {
+ mockListWorkspaceFiles.mockResolvedValue([
+ generatedDocument('f1', 'first.docx'),
+ generatedDocument('f2', 'second.docx'),
+ ])
+ mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => {
+ // The first document eats the whole budget, so the second's cap is the remainder.
+ if (file.name === 'first.docx') {
+ return { buffer: Buffer.alloc(240 * MB), contentType: 'application/octet-stream' }
+ }
+ throw new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 })
+ })
+
+ const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context)
+
+ expect(response.status).toBe(400)
+ const body = await response.json()
+ expect(body.error).toContain('once documents are rendered')
+ expect(body.error).not.toContain('second.docx')
+ })
+
+ it.each([
+ ['text/x-docxjs', 'report.docx'],
+ ['text/x-pptxgenjs', 'deck.pptx'],
+ ['text/x-pdflibjs', 'isolated.pdf'],
+ ['text/x-python-pdf', 'sandboxed.pdf'],
+ ['text/x-python-xlsx', 'sheet.xlsx'],
+ ])('resolves %s rather than streaming its source', async (type, name) => {
+ // Both PDF generators must be covered: the isolated-vm path stores pdf-lib JS and
+ // the E2B path stores Python, and either one streamed raw is the corruption bug.
+ mockListWorkspaceFiles.mockResolvedValue([{ ...workspaceFile('f1', name), type }])
+ mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
+ buffer: Buffer.from('PKrendered'),
+ contentType: 'application/octet-stream',
+ })
+
+ await zipFrom(await GET(requestFor('fileIds=f1'), context))
+
+ expect(mockFetchServableWorkspaceFileBuffer).toHaveBeenCalledTimes(1)
+ expect(mockDownloadFileStream).not.toHaveBeenCalled()
+ })
+
+ it('streams an uploaded office file rather than resolving it', async () => {
+ // A real upload serves exactly its stored bytes, so it must not take the buffered
+ // path — otherwise a selection of large decks is held in memory for nothing.
+ const upload = {
+ ...workspaceFile('f1', 'deck.pptx'),
+ size: 80 * MB,
+ type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ }
+ mockListWorkspaceFiles.mockResolvedValue([upload])
+
+ const response = await GET(requestFor('fileIds=f1'), context)
+ await zipFrom(response)
+
+ expect(response.status).toBe(200)
+ expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled()
+ expect(mockDownloadFileStream).toHaveBeenCalledTimes(1)
+ })
+
+ it('caps a generated document at the render headroom', async () => {
+ mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'report.docx')])
+ mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
+ buffer: Buffer.from('PKdoc'),
+ contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ })
+
+ await zipFrom(await GET(requestFor('fileIds=f1'), context))
+
+ expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB)
+ })
+
+ it('surfaces a storage failure as a 500 even when another document is pending', async () => {
+ mockListWorkspaceFiles.mockResolvedValue([
+ generatedDocument('f1', 'pending.docx'),
+ generatedDocument('f2', 'broken.docx'),
+ ])
+ mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => {
+ if (file.name === 'pending.docx')
+ throw new DocCompileUserError('Document is still being generated')
+ throw new Error('storage down')
+ })
+
+ const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context)
+
+ // A 409 would tell the client to retry something that can never succeed.
+ expect(response.status).toBe(500)
+ })
+
+ it('stops resolving documents once one hard-fails', async () => {
+ const files = Array.from({ length: 20 }, (_, index) =>
+ generatedDocument(`f${index}`, `doc${index}.docx`)
+ )
+ mockListWorkspaceFiles.mockResolvedValue(files)
+ mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => {
+ if (file.name === 'doc0.docx') throw new Error('storage down')
+ return { buffer: Buffer.from('PKok'), contentType: 'application/octet-stream' }
+ })
+
+ const response = await GET(
+ requestFor(files.map((file) => `fileIds=${file.id}`).join('&')),
+ context
+ )
+
+ expect(response.status).toBe(500)
+ expect(mockFetchServableWorkspaceFileBuffer.mock.calls.length).toBeLessThan(files.length)
+ })
+
+ it('rejects a selection whose declared sizes already exceed the limit', async () => {
+ mockListWorkspaceFiles.mockResolvedValue([
+ { ...workspaceFile('f1', 'a.mp4'), size: 200 * MB },
+ { ...workspaceFile('f2', 'b.mp4'), size: 200 * MB },
+ ])
+
+ const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context)
+
+ expect(response.status).toBe(400)
+ expect(mockDownloadFileStream).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts
index 577f0e7b2dc..67b070f303f 100644
--- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts
@@ -1,19 +1,30 @@
+import { Readable } from 'node:stream'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
-import JSZip from 'jszip'
+import { ZipArchive } from 'archiver'
import { type NextRequest, NextResponse } from 'next/server'
import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream'
+import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
+import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import {
buildWorkspaceFileFolderPathMap,
- fetchWorkspaceFileBuffer,
+ fetchServableWorkspaceFileBuffer,
listWorkspaceFileFolders,
listWorkspaceFiles,
} from '@/lib/uploads/contexts/workspace'
-import { formatFileSize } from '@/lib/uploads/utils/file-utils'
+import { downloadFileStream } from '@/lib/uploads/core/storage-service'
+import {
+ formatFileSize,
+ isGeneratedDocumentSourceType,
+ isRenderableDocumentName,
+ MAX_RENDERED_DOCUMENT_BYTES,
+} from '@/lib/uploads/utils/file-utils'
+import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response'
import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -21,6 +32,62 @@ const logger = createLogger('WorkspaceFilesDownloadAPI')
const MAX_ZIP_DOWNLOAD_FILES = 100
const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024
+/**
+ * Whether this entry's stored bytes are a generation source that has to be resolved
+ * before it can go in the archive. An ordinary uploaded `.docx` serves exactly what is
+ * stored, so it streams like anything else — routing every office extension through the
+ * buffered path would hold a whole selection of real documents in memory for a check
+ * the resolver settles on the first few magic bytes. Metadata without a type falls back
+ * to the extension: better to resolve and pass through than to stream source text under
+ * a document name.
+ */
+function needsRendering(file: WorkspaceFileRecord): boolean {
+ return file.type ? isGeneratedDocumentSourceType(file.type) : isRenderableDocumentName(file.name)
+}
+
+/**
+ * A `Readable` that opens its storage read on first pull rather than up front. The
+ * archiver works through entries sequentially, so handing it an open stream per entry
+ * would hold a connection per selected file — more than the storage client pools — with
+ * most sitting idle until their turn. The generator body does not run until the first
+ * read, and a failure to open surfaces as the stream's `error` event.
+ */
+function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable {
+ return Readable.from(
+ (async function* () {
+ yield* await downloadFileStream({
+ key: file.key,
+ context: file.storageContext ?? 'workspace',
+ })
+ })(),
+ // `Readable.from` defaults to object mode; these are bytes headed for an archive.
+ { objectMode: false }
+ )
+}
+
+function selectionTooLargeResponse(bytes: number): NextResponse {
+ return NextResponse.json(
+ {
+ error: `Selected files total ${formatFileSize(bytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`,
+ },
+ { status: 400 }
+ )
+}
+
+/**
+ * The rendered archive would exceed the limit even though the declared sizes did not —
+ * generated documents render to more than the source they declared, so no accurate byte
+ * count exists to quote here.
+ */
+function archiveTooLargeResponse(): NextResponse {
+ return NextResponse.json(
+ {
+ error: `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.`,
+ },
+ { status: 400 }
+ )
+}
+
function collectDescendantFolderIds(
selectedFolderIds: string[],
folders: Array<{ id: string; parentId: string | null }>
@@ -82,17 +149,61 @@ export const GET = withRouteHandler(
)
}
- const totalBytes = filesToZip.reduce((sum, file) => sum + file.size, 0)
- if (totalBytes > MAX_ZIP_DOWNLOAD_BYTES) {
- return NextResponse.json(
- {
- error: `Selected files total ${formatFileSize(totalBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`,
- },
- { status: 400 }
- )
+ const declaredBytes = filesToZip.reduce((sum, file) => sum + file.size, 0)
+ if (declaredBytes > MAX_ZIP_DOWNLOAD_BYTES) {
+ return selectionTooLargeResponse(declaredBytes)
}
- const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file)))
+ // Streamed entries ship exactly what they declared, so their share of the budget is
+ // known up front and is reserved here. Documents then draw from what is left —
+ // one budget across both kinds, or the archive could ship two full limits' worth.
+ const reservedForStreamed = filesToZip
+ .filter((file) => !needsRendering(file))
+ .reduce((sum, file) => sum + file.size, 0)
+
+ // Generated documents are resolved before the archive starts: once the first byte
+ // is written the status code is committed, so anything that can still fail the
+ // request has to fail here. Their buffers are held until the archive is assembled,
+ // bounded by what is left of the request's byte budget.
+ const renderedDocuments = new Map()
+ const pendingNames: string[] = []
+ let renderedBytes = 0
+
+ for (const file of filesToZip) {
+ if (!needsRendering(file)) continue
+
+ const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes)
+ // A source's declared size says nothing about what it renders to, so the cap is
+ // the per-document ceiling, bounded by what is left of the budget.
+ const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES)
+
+ try {
+ const { buffer } = await fetchServableWorkspaceFileBuffer(file, { maxBytes: allowance })
+ renderedBytes += buffer.length
+ renderedDocuments.set(file.id, buffer)
+ } catch (error) {
+ if (error instanceof PayloadSizeLimitError) {
+ // Blamed on the entry when its own ceiling was the binding cap; otherwise the
+ // documents ahead of it have consumed the budget.
+ return allowance === MAX_RENDERED_DOCUMENT_BYTES
+ ? NextResponse.json(
+ {
+ error: `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to include in a zip; download it on its own instead.`,
+ },
+ { status: 400 }
+ )
+ : archiveTooLargeResponse()
+ }
+ // Pending artifacts are collected so the 409 can name all of them; anything
+ // else dooms the request and waiting cannot fix it.
+ if (!isDocNotReadyError(error)) throw error
+ pendingNames.push(file.name)
+ }
+ }
+
+ if (pendingNames.length > 0) {
+ return NextResponse.json({ error: docNotReadyMessage(pendingNames) }, { status: 409 })
+ }
// Entry paths stay workspace-root-relative so a mixed selection of folders and
// loose files keeps the layout the user sees in the files list.
@@ -103,12 +214,22 @@ export const GET = withRouteHandler(
}))
)
- const zip = new JSZip()
- for (const [index, buffer] of buffers.entries()) {
- zip.file(entryPaths[index], buffer)
- }
+ // Ordinary files are never materialized: each entry opens its storage read only
+ // when the archiver reaches it, so one entry is resident rather than the archive.
+ const archive = new ZipArchive({ store: true })
+ archive.on('warning', (error: Error) => {
+ logger.warn('Archive warning while streaming workspace files', { error })
+ })
- const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
+ filesToZip.forEach((file, index) => {
+ const rendered = renderedDocuments.get(file.id)
+ archive.append(rendered ?? lazyWorkspaceFileStream(file), { name: entryPaths[index] })
+ })
+ archive.finalize().catch((error) => {
+ // The archive's `error` event already fails the response stream; this keeps the
+ // same failure from also surfacing as an unhandled rejection.
+ logger.error('Failed to finalize workspace file archive', { error })
+ })
recordAudit({
workspaceId,
@@ -116,7 +237,7 @@ export const GET = withRouteHandler(
action: AuditAction.FILE_DOWNLOADED,
resourceType: AuditResourceType.FILE,
description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`,
- metadata: { fileCount: filesToZip.length, totalBytes },
+ metadata: { fileCount: filesToZip.length, totalBytes: declaredBytes },
request,
})
captureServerEvent(
@@ -126,7 +247,8 @@ export const GET = withRouteHandler(
{ groups: { workspace: workspaceId } }
)
- return new NextResponse(new Uint8Array(zipBuffer), {
+ // No Content-Length: the archive size is not known until it has been produced.
+ return new NextResponse(nodeReadableToWebStream(archive), {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="workspace-files.zip"',
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts
index cb6defd8d95..984d2b2e1de 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
import { toast } from '@sim/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import { GENERATED_DOCUMENT_SOURCE_TYPES } from '@/lib/uploads/utils/file-utils'
import {
useUpdateWorkspaceFileContent,
useWorkspaceFileContent,
@@ -20,13 +21,7 @@ import {
* editable text is the source program, not the compiled artifact. The serve route
* returns that source only when asked for the raw representation.
*/
-const GENERATED_SOURCE_FILE_TYPES = new Set([
- 'text/x-pptxgenjs',
- 'text/x-docxjs',
- 'text/x-pdflibjs',
- 'text/x-python-pdf',
- 'text/x-python-xlsx',
-])
+const GENERATED_SOURCE_FILE_TYPES = GENERATED_DOCUMENT_SOURCE_TYPES
/**
* Poll cadence for the content query while the post-stream reconcile waits for a fetch showing the
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index c6bcecde52d..051a807f233 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -27,7 +27,7 @@ import { usePostHog } from 'posthog-js/react'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { useLimitUpgradeToast } from '@/lib/billing/client'
import { captureEvent } from '@/lib/posthog/client'
-import { triggerFileDownload } from '@/lib/uploads/client/download'
+import { triggerArchiveDownload, triggerFileDownload } from '@/lib/uploads/client/download'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
import {
@@ -951,6 +951,7 @@ export function Files() {
})
} catch (err) {
logger.error('Failed to download file:', err)
+ toast.error(getErrorMessage(err, `Failed to download "${file.name}"`))
}
},
[workspaceId]
@@ -1071,25 +1072,44 @@ export function Files() {
setShowDeleteConfirm(true)
}, [selectedFileIds, selectedFolderIds, files, folders])
- const handleBulkDownload = useCallback(() => {
+ const [isDownloadingArchive, setIsDownloadingArchive] = useState(false)
+ // Ref as well as state: two clicks in the same tick would both pass a state check,
+ // and each concurrent archive holds the whole zip in tab memory.
+ const archiveDownloadInFlightRef = useRef(false)
+
+ const downloadArchive = useCallback(
+ async (selection: { fileIds?: string[]; folderIds?: string[] }) => {
+ if (archiveDownloadInFlightRef.current) return
+ archiveDownloadInFlightRef.current = true
+ setIsDownloadingArchive(true)
+ try {
+ await triggerArchiveDownload({ workspaceId, ...selection })
+ } catch (err) {
+ logger.error('Failed to download selection:', err)
+ toast.error(getErrorMessage(err, 'Failed to download the selected files'))
+ } finally {
+ archiveDownloadInFlightRef.current = false
+ setIsDownloadingArchive(false)
+ }
+ },
+ [workspaceId]
+ )
+
+ const handleBulkDownload = useCallback(async () => {
const selectedFiles = files.filter((file) => selectedFileIds.includes(file.id))
if (selectedFiles.length === 1 && selectedFolderIds.length === 0) {
handleDownload(selectedFiles[0])
return
}
- const query = new URLSearchParams()
- for (const fileId of selectedFileIds) query.append('fileIds', fileId)
- for (const folderId of selectedFolderIds) query.append('folderIds', folderId)
-
- if (query.size === 0) return
+ if (selectedFileIds.length === 0 && selectedFolderIds.length === 0) return
captureEvent(posthogRef.current, 'file_downloaded', {
workspace_id: workspaceId,
is_bulk: true,
file_count: selectedFileIds.length + selectedFolderIds.length,
})
- window.location.href = `/api/workspaces/${workspaceId}/files/download?${query.toString()}`
- }, [selectedFileIds, selectedFolderIds, files, handleDownload, workspaceId])
+ await downloadArchive({ fileIds: selectedFileIds, folderIds: selectedFolderIds })
+ }, [selectedFileIds, selectedFolderIds, files, handleDownload, downloadArchive, workspaceId])
const fileDetailBreadcrumbs = useMemo(() => {
if (!selectedFile) return []
@@ -1280,18 +1300,19 @@ export function Files() {
if (!item) return
const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id)
if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) {
- handleBulkDownload()
+ void handleBulkDownload()
closeContextMenu()
return
}
if (item.kind === 'folder') {
- window.location.href = `/api/workspaces/${workspaceId}/files/download?folderIds=${encodeURIComponent(item.folder.id)}`
+ const folderId = item.folder.id
closeContextMenu()
+ void downloadArchive({ folderIds: [folderId] })
return
}
handleDownload(item.file)
closeContextMenu()
- }, [selectedRowIds, handleBulkDownload, closeContextMenu, workspaceId, handleDownload])
+ }, [selectedRowIds, handleBulkDownload, closeContextMenu, downloadArchive, handleDownload])
const handleContextMenuRename = useCallback(() => {
const item = contextMenuItemRef.current
@@ -1981,7 +2002,9 @@ export function Files() {
onMove={canEdit ? handleContextMenuMove : undefined}
moveOptions={canEdit ? contextMenuMoveOptions : undefined}
onDelete={canEdit ? handleBulkDelete : undefined}
- isLoading={bulkArchiveItems.isPending || moveItems.isPending}
+ isLoading={
+ bulkArchiveItems.isPending || moveItems.isPending || isDownloadingArchive
+ }
/>
{isDraggingOver ? (
diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts
index f6494b14aa0..51dabd8de14 100644
--- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts
+++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts
@@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
const {
mockIsFeatureEnabled,
@@ -16,6 +17,7 @@ const {
mockListWorkspaceFiles,
mockFindWorkspaceFileRecord,
mockFetchWorkspaceFileBuffer,
+ mockFetchServableWorkspaceFileBuffer,
mockGetSandboxWorkspaceFilePath,
mockListWorkspaceFileFolders,
} = vi.hoisted(() => ({
@@ -31,6 +33,7 @@ const {
mockListWorkspaceFiles: vi.fn(),
mockFindWorkspaceFileRecord: vi.fn(),
mockFetchWorkspaceFileBuffer: vi.fn(),
+ mockFetchServableWorkspaceFileBuffer: vi.fn(),
mockGetSandboxWorkspaceFilePath: vi.fn(),
mockListWorkspaceFileFolders: vi.fn(),
}))
@@ -52,6 +55,7 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
+ fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer,
fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer,
findWorkspaceFileRecord: mockFindWorkspaceFileRecord,
getSandboxWorkspaceFilePath: mockGetSandboxWorkspaceFilePath,
@@ -309,6 +313,61 @@ describe('executeFunctionExecute file mounts', () => {
expect(file.type).toBeUndefined()
})
+ describe('generated documents', () => {
+ const docRecord = {
+ ...fileRecord,
+ name: 'report.docx',
+ key: 'workspace/ws_1/report.docx',
+ // The stored bytes are the generator source, so the record declares its size.
+ type: 'text/x-docxjs',
+ size: 6_242,
+ }
+
+ beforeEach(() => {
+ mockFindWorkspaceFileRecord.mockReturnValue(docRecord)
+ mockListWorkspaceFiles.mockResolvedValue([docRecord])
+ mockGetSandboxWorkspaceFilePath.mockReturnValue('/home/user/files/report.docx')
+ mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
+ buffer: Buffer.from('PK\u0003\u0004rendered-docx'),
+ contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ })
+ })
+
+ it('never presigns the raw key, even on cloud storage', async () => {
+ mockHasCloudStorage.mockReturnValue(true)
+
+ await executeFunctionExecute({ inputFiles: ['files/report.docx'] }, context as never)
+
+ // Presigning record.key would hand the sandbox the generator source.
+ expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
+ expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled()
+ expect(mockFetchServableWorkspaceFileBuffer).toHaveBeenCalledTimes(1)
+ })
+
+ it('mounts the rendered bytes as base64, not utf-8', async () => {
+ mockHasCloudStorage.mockReturnValue(true)
+
+ await executeFunctionExecute({ inputFiles: ['files/report.docx'] }, context as never)
+
+ const file = mountedFiles()[0]
+ // record.type is text/x-docxjs; keying off it would utf-8 decode a binary.
+ expect(file.encoding).toBe('base64')
+ expect(Buffer.from(file.content as string, 'base64').toString()).toContain('rendered-docx')
+ })
+
+ it('budgets the mount on rendered length, not the declared source size', async () => {
+ mockHasCloudStorage.mockReturnValue(true)
+ // A tiny source that renders past the aggregate mount budget.
+ mockFetchServableWorkspaceFileBuffer.mockRejectedValue(
+ new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 })
+ )
+
+ await expect(
+ executeFunctionExecute({ inputFiles: ['files/report.docx'] }, context as never)
+ ).rejects.toThrow(/mount limit/)
+ })
+ })
+
it('cloud storage: throws when a file exceeds the per-file URL mount limit', async () => {
mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 600 * 1024 * 1024 })
diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts
index 0fbb4cebafd..828e27b5956 100644
--- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts
+++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts
@@ -3,6 +3,7 @@ import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { getColumnId } from '@/lib/table/column-keys'
import { formatCsvCell, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
import { queryRows } from '@/lib/table/rows/service'
@@ -10,6 +11,7 @@ import { getTableById, listTables } from '@/lib/table/service'
import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache'
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
+ fetchServableWorkspaceFileBuffer,
fetchWorkspaceFileBuffer,
findWorkspaceFileRecord,
getSandboxWorkspaceFilePath,
@@ -21,6 +23,7 @@ import {
generatePresignedDownloadUrl,
hasCloudStorage,
} from '@/lib/uploads/core/storage-service'
+import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils'
import { executeTool as executeAppTool } from '@/tools'
import type { ToolExecutionContext, ToolExecutionResult } from '../../tool-executor/types'
@@ -87,7 +90,14 @@ async function pushWorkspaceFileMount(
mountPath: string,
mounted: MountedBytes
): Promise {
- if (hasCloudStorage()) {
+ // A generated document stores its generator source, so a presigned URL for
+ // `record.key` would hand the sandbox source text under a `.docx` name and the
+ // user's script would fail on a file that looks fine. Those resolve through the
+ // servable reader instead — they are bounded by the render ceiling, so routing them
+ // through the web process rather than presigning is affordable.
+ const rendersFromSource = isGeneratedDocumentSourceType(record.type)
+
+ if (hasCloudStorage() && !rendersFromSource) {
if (record.size > MOUNT_URL_MAX_BYTES) {
throw new Error(
`Input file "${mountPath}" is ${Math.round(record.size / 1024 / 1024)}MB, over the ${MOUNT_URL_MAX_BYTES / 1024 / 1024}MB per-file mount limit.`
@@ -108,19 +118,38 @@ async function pushWorkspaceFileMount(
return
}
- if (record.size > MAX_FILE_SIZE) {
- throw new Error(
- `Input file "${mountPath}" is ${Math.round(record.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.`
- )
- }
- if (mounted.buffered + record.size > MAX_TOTAL_SIZE) {
- throw new Error(
- `Mounting "${mountPath}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller files.`
- )
+ const remainingBudget = Math.max(0, MAX_TOTAL_SIZE - mounted.buffered)
+
+ // A source-backed document declares the size of its generator, not of the document,
+ // so these pre-checks say nothing about what is about to be mounted. Its read is
+ // capped instead, and the real length is checked once it is known.
+ if (!rendersFromSource) {
+ if (record.size > MAX_FILE_SIZE) {
+ throw new Error(
+ `Input file "${mountPath}" is ${Math.round(record.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.`
+ )
+ }
+ if (record.size > remainingBudget) {
+ throw new Error(
+ `Mounting "${mountPath}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller files.`
+ )
+ }
}
- const buffer = await fetchWorkspaceFileBuffer(record)
+
+ const { buffer, contentType } = rendersFromSource
+ ? await fetchServableWorkspaceFileBuffer(record, {
+ maxBytes: Math.min(MAX_FILE_SIZE, remainingBudget),
+ }).catch((error) => {
+ if (!isPayloadSizeLimitError(error)) throw error
+ throw new Error(
+ `Input file "${mountPath}" renders to more than the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit, or than the mount budget left. Mount fewer or smaller files.`
+ )
+ })
+ : { buffer: await fetchWorkspaceFileBuffer(record), contentType: record.type }
+ // Keyed off the resolved type: a rendered document's source MIME is `text/x-…`, and
+ // decoding the binary as UTF-8 would corrupt it just as surely as shipping the source.
const isText = /^text\/|application\/json|application\/xml|application\/csv/.test(
- record.type || ''
+ contentType || ''
)
sandboxFiles.push({
path: mountPath,
diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts
index e8ea4071577..02c44a1ed97 100644
--- a/apps/sim/lib/core/security/input-validation.server.ts
+++ b/apps/sim/lib/core/security/input-validation.server.ts
@@ -18,6 +18,7 @@ import {
} from 'undici'
import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags'
import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation'
+import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
const logger = createLogger('InputValidation')
@@ -717,67 +718,6 @@ function contentEncodingDecoder(
}
}
-/**
- * Bridges an undici `request()` Node `Readable` into a WHATWG `ReadableStream` for a
- * `Response` body. Node's built-in `Readable.toWeb` is NOT used: its adapter throws an
- * unhandled `ERR_INVALID_STATE` ("Controller is already closed") when the web stream is
- * cancelled while the Node stream is still flowing — which `followRedirectsGuarded` does
- * on every redirect hop (`response.body.cancel()`). This bridge instead swallows a late
- * enqueue after close and destroys the source on cancel, so cancelling a live body frees
- * its socket cleanly. `maxResponseSize` overruns surface as the source's `error` event and
- * reject the read, preserving the DoS backstop.
- */
-function nodeReadableToWebStream(nodeStream: Readable): ReadableStream {
- let settled = false
- return new ReadableStream({
- start(controller) {
- nodeStream.on('data', (chunk: Buffer) => {
- try {
- // Copy, not a view: undici may recycle the pooled buffer backing `chunk` after
- // this handler returns, which would corrupt a chunk still queued for a slow
- // consumer. `new Uint8Array(chunk)` allocates a fresh backing buffer.
- controller.enqueue(new Uint8Array(chunk))
- } catch {
- // Controller already closed (consumer cancelled) — stop the source, drop the chunk.
- nodeStream.destroy()
- return
- }
- if ((controller.desiredSize ?? 1) <= 0) nodeStream.pause()
- })
- nodeStream.once('end', () => {
- settled = true
- try {
- controller.close()
- } catch {}
- })
- nodeStream.once('error', (err) => {
- settled = true
- try {
- controller.error(err)
- } catch {}
- })
- // An abort (signal) or upstream reset can `destroy()` the source with no `error`
- // event; without this the reader would hang forever. `close` fires after every
- // terminal path, so only act when `end`/`error` didn't already settle the stream.
- nodeStream.once('close', () => {
- if (settled) return
- settled = true
- try {
- controller.error(new Error('MCP transport stream closed before completing'))
- } catch {}
- })
- // Start paused so nothing buffers before the consumer pulls (backpressure).
- nodeStream.pause()
- },
- pull() {
- nodeStream.resume()
- },
- cancel(reason) {
- nodeStream.destroy(reason instanceof Error ? reason : undefined)
- },
- })
-}
-
/**
* Streaming-safe replacement for `undiciFetch(url, { ...init, dispatcher })`.
*
diff --git a/apps/sim/lib/core/utils/node-stream.ts b/apps/sim/lib/core/utils/node-stream.ts
new file mode 100644
index 00000000000..1288a2280d7
--- /dev/null
+++ b/apps/sim/lib/core/utils/node-stream.ts
@@ -0,0 +1,63 @@
+import type { Readable } from 'node:stream'
+
+/**
+ * Bridges a Node `Readable` into a WHATWG `ReadableStream` suitable for a `Response`
+ * body. Node's built-in `Readable.toWeb` is NOT used: its adapter throws an unhandled
+ * `ERR_INVALID_STATE` ("Controller is already closed") when the web stream is cancelled
+ * while the Node stream is still flowing — which happens whenever a consumer aborts a
+ * live body (a redirect hop cancelling the previous response, a browser cancelling a
+ * download mid-transfer). This bridge instead swallows a late enqueue after close and
+ * destroys the source on cancel, so cancelling a live body frees its socket cleanly.
+ * Source errors — a size-limit overrun, a storage read failing mid-archive — surface as
+ * the source's `error` event and reject the read.
+ */
+export function nodeReadableToWebStream(nodeStream: Readable): ReadableStream {
+ let settled = false
+ return new ReadableStream({
+ start(controller) {
+ nodeStream.on('data', (chunk: Buffer) => {
+ try {
+ // Copy, not a view: the producer may recycle the pooled buffer backing `chunk`
+ // after this handler returns, which would corrupt a chunk still queued for a
+ // slow consumer. `new Uint8Array(chunk)` allocates a fresh backing buffer.
+ controller.enqueue(new Uint8Array(chunk))
+ } catch {
+ // Controller already closed (consumer cancelled) — stop the source, drop the chunk.
+ nodeStream.destroy()
+ return
+ }
+ if ((controller.desiredSize ?? 1) <= 0) nodeStream.pause()
+ })
+ nodeStream.once('end', () => {
+ settled = true
+ try {
+ controller.close()
+ } catch {}
+ })
+ nodeStream.once('error', (err) => {
+ settled = true
+ try {
+ controller.error(err)
+ } catch {}
+ })
+ // An abort or upstream reset can `destroy()` the source with no `error` event;
+ // without this the reader would hang forever. `close` fires after every terminal
+ // path, so only act when `end`/`error` didn't already settle the stream.
+ nodeStream.once('close', () => {
+ if (settled) return
+ settled = true
+ try {
+ controller.error(new Error('Stream closed before completing'))
+ } catch {}
+ })
+ // Start paused so nothing buffers before the consumer pulls (backpressure).
+ nodeStream.pause()
+ },
+ pull() {
+ nodeStream.resume()
+ },
+ cancel(reason) {
+ nodeStream.destroy(reason instanceof Error ? reason : undefined)
+ },
+ })
+}
diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts
index 9cbdd88d263..ac873f66ae8 100644
--- a/apps/sim/lib/uploads/client/download.ts
+++ b/apps/sim/lib/uploads/client/download.ts
@@ -1,5 +1,35 @@
+import { requestRaw } from '@/lib/api/client/request'
+import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+export function saveBlob(blob: Blob, fileName: string): void {
+ const objectUrl = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = objectUrl
+ anchor.download = fileName
+ // Attached before clicking: a detached anchor works in current browsers, but every
+ // other download helper in the app attaches, and a silent no-op here would look
+ // exactly like a download that never started.
+ document.body.appendChild(anchor)
+ anchor.click()
+ document.body.removeChild(anchor)
+ // Deferred: revoking synchronously after click() can race the download starting.
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
+}
+
+function fileNameFromDisposition(response: Response, fallback: string): string {
+ const disposition = response.headers.get('Content-Disposition') ?? ''
+ const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
+ if (encoded) {
+ try {
+ return decodeURIComponent(encoded)
+ } catch {
+ // Fall through to the plain form.
+ }
+ }
+ return disposition.match(/filename="([^"]+)"/)?.[1] ?? fallback
+}
+
export async function triggerFileDownload(record: WorkspaceFileRecord): Promise {
const isMarkdown =
record.type === 'text/markdown' ||
@@ -10,17 +40,32 @@ export async function triggerFileDownload(record: WorkspaceFileRecord): Promise<
? `/api/files/export/${encodeURIComponent(record.id)}`
: `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}`
+ // boundary-raw-fetch: binary download read as a blob; these paths have no contract
const response = await fetch(url, { cache: 'no-store' })
- if (!response.ok) throw new Error(`Failed to download file: ${response.statusText}`)
+ if (!response.ok) throw new Error(`Failed to download "${record.name}"`)
- const blob = await response.blob()
- const objectUrl = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = objectUrl
- a.download =
- response.headers.get('Content-Disposition')?.match(/filename="([^"]+)"/)?.[1] ?? record.name
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(objectUrl)
+ saveBlob(await response.blob(), fileNameFromDisposition(response, record.name))
+}
+
+/**
+ * Download a selection of files as a zip. Fetched rather than navigated to, so a
+ * rejection — a document still compiling, an entry too large — surfaces as an error the
+ * caller can show in place instead of replacing the page with raw JSON. `requestRaw`
+ * throws an `ApiClientError` carrying the route's own message.
+ */
+export async function triggerArchiveDownload(input: {
+ workspaceId: string
+ fileIds?: string[]
+ folderIds?: string[]
+}): Promise {
+ const response = await requestRaw(
+ downloadWorkspaceFileItemsContract,
+ {
+ params: { id: input.workspaceId },
+ query: { fileIds: input.fileIds ?? [], folderIds: input.folderIds ?? [] },
+ },
+ { cache: 'no-store' }
+ )
+
+ saveBlob(await response.blob(), fileNameFromDisposition(response, 'workspace-files.zip'))
}
diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
index 6967a7d313f..b1ae9b45da2 100644
--- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
+++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
@@ -21,6 +21,7 @@ import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases'
+import { generateRequestId } from '@/lib/core/utils/request'
import { generateRestoreName } from '@/lib/core/utils/restore-name'
import type { DbOrTx } from '@/lib/db/types'
import { getServePathPrefix } from '@/lib/uploads'
@@ -968,7 +969,42 @@ export async function getWorkspaceFile(
}
/**
- * Download workspace file content
+ * Download the bytes a user should actually receive for a workspace file.
+ *
+ * Generated docs (docx/pptx/pdf/xlsx) store their GENERATION SOURCE as the primary
+ * file, so {@link fetchWorkspaceFileBuffer} hands back JavaScript/Python text under
+ * a `.docx` name. This resolves the rendered artifact instead, and is what every
+ * download/attachment surface should call. Reach for the raw reader only when the
+ * source itself is wanted (style extraction, compile checks, the copilot VFS).
+ *
+ * Throws `DocCompileUserError` when a generated doc's artifact is still compiling —
+ * callers turn that into a retryable 409 rather than shipping source.
+ */
+export async function fetchServableWorkspaceFileBuffer(
+ fileRecord: WorkspaceFileRecord,
+ options: { maxBytes?: number; signal?: AbortSignal; requestId?: string } = {}
+): Promise<{ buffer: Buffer; contentType: string }> {
+ const { downloadServableFileFromStorage } = await import('@/lib/uploads/utils/file-utils.server')
+
+ return downloadServableFileFromStorage(
+ {
+ id: fileRecord.id,
+ name: fileRecord.name,
+ url: fileRecord.url ?? fileRecord.path,
+ size: fileRecord.size,
+ type: fileRecord.type,
+ key: fileRecord.key,
+ context: fileRecord.storageContext ?? 'workspace',
+ },
+ options.requestId ?? generateRequestId(),
+ logger,
+ options
+ )
+}
+
+/**
+ * Download raw workspace file content. For generated docs this is the GENERATION
+ * SOURCE, not the rendered document — see {@link fetchServableWorkspaceFileBuffer}.
*/
export async function fetchWorkspaceFileBuffer(
fileRecord: WorkspaceFileRecord,
diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts
index cc230df8108..97013439e77 100644
--- a/apps/sim/lib/uploads/utils/file-utils.server.ts
+++ b/apps/sim/lib/uploads/utils/file-utils.server.ts
@@ -20,6 +20,7 @@ import {
getMimeTypeFromExtension,
inferContextFromKey,
isInternalFileUrl,
+ isRenderableDocumentName,
processSingleFileToUserFile,
type RawFileInput,
resolveTrustedFileContext,
@@ -375,8 +376,8 @@ export async function downloadServableFileFromStorage(
// Cheap pre-filter so only generated-doc candidates pay for the heavier resolver
// import below.
- const ext = getFileExtension(userFile.name)
- if (ext !== 'pdf' && ext !== 'docx' && ext !== 'pptx' && ext !== 'xlsx') {
+ if (!isRenderableDocumentName(userFile.name)) {
+ const ext = getFileExtension(userFile.name)
return { buffer, contentType: userFile.type || getMimeTypeFromExtension(ext) }
}
diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts
index 7837617a0cc..cd36052fdbe 100644
--- a/apps/sim/lib/uploads/utils/file-utils.ts
+++ b/apps/sim/lib/uploads/utils/file-utils.ts
@@ -210,6 +210,44 @@ export function getFileExtension(filename: string): string {
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
}
+/**
+ * Extensions whose stored bytes may be a generation source that renders to a larger
+ * binary. Everything else stores exactly what it serves, so its declared size is
+ * an accurate byte budget.
+ */
+const RENDERABLE_DOCUMENT_EXTENSIONS = new Set(['pdf', 'docx', 'pptx', 'xlsx'])
+
+/**
+ * Content types under which a generated document's *generation source* is stored. A
+ * file carrying one of these renders to something other than its stored bytes, so any
+ * surface that hands out the file itself has to resolve it first. Both PDF generators
+ * are here: the E2B path stores Python, the isolated-vm path stores pdf-lib JS.
+ */
+export const GENERATED_DOCUMENT_SOURCE_TYPES = new Set([
+ 'text/x-docxjs',
+ 'text/x-pptxgenjs',
+ 'text/x-pdflibjs',
+ 'text/x-python-pdf',
+ 'text/x-python-xlsx',
+])
+
+/** True when the stored bytes for `contentType` are a generation source. */
+export function isGeneratedDocumentSourceType(contentType: string | undefined | null): boolean {
+ return contentType ? GENERATED_DOCUMENT_SOURCE_TYPES.has(contentType) : false
+}
+
+/**
+ * Ceiling on a single rendered generated document. A generator source is text and is
+ * orders of magnitude smaller than the document it produces, so the declared size is no
+ * bound at all and the rendered bytes need a cap of their own.
+ */
+export const MAX_RENDERED_DOCUMENT_BYTES = 50 * 1024 * 1024
+
+/** True when `fileName` may be backed by a generation source rather than final bytes. */
+export function isRenderableDocumentName(fileName: string): boolean {
+ return RENDERABLE_DOCUMENT_EXTENSIONS.has(getFileExtension(fileName))
+}
+
const ARCHIVE_EXTENSIONS = new Set(SUPPORTED_ARCHIVE_EXTENSIONS)
/**
diff --git a/apps/sim/lib/uploads/utils/servable-file-response.ts b/apps/sim/lib/uploads/utils/servable-file-response.ts
index 1e7d1a6124b..63ffb0223f0 100644
--- a/apps/sim/lib/uploads/utils/servable-file-response.ts
+++ b/apps/sim/lib/uploads/utils/servable-file-response.ts
@@ -1,6 +1,23 @@
import { NextResponse } from 'next/server'
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile'
+/** True when `error` means a generated document's artifact is still compiling. */
+export function isDocNotReadyError(error: unknown): error is DocCompileUserError {
+ return error instanceof DocCompileUserError
+}
+
+/**
+ * Message for a still-compiling generated document. Batch callers pass the names
+ * they resolved so the copy says which documents to wait on.
+ */
+export function docNotReadyMessage(fileNames?: string[]): string {
+ if (!fileNames || fileNames.length === 0) {
+ return 'A document is still being generated. Wait for it to finish, then try again.'
+ }
+ const subject = fileNames.length === 1 ? 'A document is' : `${fileNames.length} documents are`
+ return `${subject} still being generated: ${fileNames.join(', ')}. Wait for them to finish, then try again.`
+}
+
/**
* Canonical retryable response for an attachment/upload whose generated-document
* artifact is still compiling. Returns the 409 when `error` is a
@@ -8,16 +25,13 @@ import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compil
* otherwise `null` so the caller falls through to its own error handling. Shared
* by every tool route that downloads workspace files so the status, body shape,
* and user-facing copy stay identical instead of being re-typed per route.
+ *
+ * Routes whose error envelope differs, or that resolved a batch and want the pending
+ * files named, build the 409 themselves from {@link docNotReadyMessage}.
*/
export function docNotReadyResponse(error: unknown): NextResponse | null {
- if (error instanceof DocCompileUserError) {
- return NextResponse.json(
- {
- success: false,
- error: 'A document is still being generated. Wait for it to finish, then try again.',
- },
- { status: 409 }
- )
+ if (isDocNotReadyError(error)) {
+ return NextResponse.json({ success: false, error: docNotReadyMessage() }, { status: 409 })
}
return null
}
diff --git a/apps/sim/package.json b/apps/sim/package.json
index 8ef25a4b95b..09b0780b222 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -132,6 +132,7 @@
"@trigger.dev/sdk": "4.4.3",
"@typescript/typescript6": "^6.0.2",
"ajv": "8.18.0",
+ "archiver": "8.0.0",
"better-auth": "1.6.23",
"binary-extensions": "3.1.0",
"browser-image-compression": "^2.0.2",
@@ -231,6 +232,7 @@
"@tailwindcss/typography": "0.5.19",
"@testing-library/jest-dom": "^6.6.3",
"@trigger.dev/build": "4.4.3",
+ "@types/archiver": "8.0.0",
"@types/busboy": "1.5.4",
"@types/fluent-ffmpeg": "2.1.28",
"@types/html-to-text": "9.0.4",
diff --git a/bun.lock b/bun.lock
index 597bc798df4..872e1e433f8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -207,6 +207,7 @@
"@trigger.dev/sdk": "4.4.3",
"@typescript/typescript6": "^6.0.2",
"ajv": "8.18.0",
+ "archiver": "8.0.0",
"better-auth": "1.6.23",
"binary-extensions": "3.1.0",
"browser-image-compression": "^2.0.2",
@@ -306,6 +307,7 @@
"@tailwindcss/typography": "0.5.19",
"@testing-library/jest-dom": "^6.6.3",
"@trigger.dev/build": "4.4.3",
+ "@types/archiver": "8.0.0",
"@types/busboy": "1.5.4",
"@types/fluent-ffmpeg": "2.1.28",
"@types/html-to-text": "9.0.4",
@@ -1880,6 +1882,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
+ "@types/archiver": ["@types/archiver@8.0.0", "", { "dependencies": { "@types/node": "*", "@types/readdir-glob": "*" } }, "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A=="],
+
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -2008,6 +2012,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+ "@types/readdir-glob": ["@types/readdir-glob@1.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg=="],
+
"@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
@@ -2162,6 +2168,8 @@
"anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="],
+ "archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="],
+
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -2184,7 +2192,7 @@
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
- "async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
+ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="],
@@ -2200,10 +2208,22 @@
"axios": ["axios@1.18.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw=="],
+ "b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
+
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+ "bare-events": ["bare-events@2.9.1", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg=="],
+
+ "bare-fs": ["bare-fs@4.7.4", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ=="],
+
+ "bare-path": ["bare-path@3.1.1", "", {}, "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ=="],
+
+ "bare-stream": ["bare-stream@2.13.3", "", { "dependencies": { "b4a": "^1.8.1", "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ=="],
+
+ "bare-url": ["bare-url@2.4.5", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ=="],
+
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
@@ -2246,6 +2266,8 @@
"buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="],
+ "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
+
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
@@ -2334,6 +2356,8 @@
"compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="],
+ "compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="],
+
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
@@ -2362,6 +2386,10 @@
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
+ "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
+
+ "crc32-stream": ["crc32-stream@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g=="],
+
"croner": ["croner@9.1.0", "", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
"cronstrue": ["cronstrue@3.3.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-iwJytzJph1hosXC09zY8F5ACDJKerr0h3/2mOxg9+5uuFObYlgK0m35uUPk4GCvhHc2abK7NfnR9oMqY0qZFAg=="],
@@ -2642,6 +2670,8 @@
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
+ "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
+
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
@@ -2676,6 +2706,8 @@
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
+ "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
+
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
@@ -2950,7 +2982,7 @@
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
- "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
+ "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
@@ -3028,6 +3060,8 @@
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
+ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
+
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
@@ -3626,10 +3660,12 @@
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
- "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
+ "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
+ "readdir-glob": ["readdir-glob@3.0.0", "", { "dependencies": { "minimatch": "^10.2.2" } }, "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw=="],
+
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
@@ -3866,6 +3902,8 @@
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
+ "streamx": ["streamx@2.28.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw=="],
+
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -3874,7 +3912,7 @@
"string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="],
- "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
@@ -3934,12 +3972,16 @@
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
- "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
+ "tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="],
"tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="],
"teeny-request": ["teeny-request@9.0.0", "", { "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.9", "stream-events": "^1.0.5", "uuid": "^9.0.0" } }, "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g=="],
+ "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
+
+ "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
+
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
@@ -4162,6 +4204,8 @@
"yoga-wasm-web": ["yoga-wasm-web@0.3.3", "", {}, "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA=="],
+ "zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="],
+
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-error": ["zod-error@1.5.0", "", { "dependencies": { "zod": "^3.20.2" } }, "sha512-zzopKZ/skI9iXpqCEPj+iLCKl9b88E43ehcU+sbRoHuwGd9F1IDVGQ70TyO6kmfiRL1g4IXkjsXK+g1gLYl4WQ=="],
@@ -4526,12 +4570,16 @@
"@trigger.dev/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
+ "@types/archiver/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
+
"@types/cors/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
"@types/node-fetch/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
"@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
+ "@types/readdir-glob/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
+
"@types/request/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
"@types/request/form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="],
@@ -4632,6 +4680,8 @@
"fetch-cookie/tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
+ "fluent-ffmpeg/async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
+
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@@ -4658,6 +4708,8 @@
"fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
+ "gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
+
"gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
@@ -4686,8 +4738,12 @@
"jsonwebtoken/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
+ "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
+
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
+ "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
+
"libmime/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
@@ -4716,8 +4772,6 @@
"neo4j-driver-bolt-connection/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
- "neo4j-driver-bolt-connection/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
-
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"next/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@@ -4778,9 +4832,7 @@
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
- "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
-
- "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
+ "readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"resend/@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="],
@@ -4810,8 +4862,6 @@
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
-
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
@@ -4820,7 +4870,7 @@
"tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
- "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+ "tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="],
@@ -5068,12 +5118,16 @@
"@trigger.dev/core/socket.io-client/engine.io-client": ["engine.io-client@6.5.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.0.0" } }, "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ=="],
+ "@types/archiver/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+
"@types/cors/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+ "@types/readdir-glob/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+
"@types/request/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@types/request/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@@ -5086,8 +5140,6 @@
"axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
- "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
-
"c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
@@ -5102,8 +5154,6 @@
"cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
- "concat-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
-
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
@@ -5112,8 +5162,6 @@
"docx/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
- "duplexify/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
-
"engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
@@ -5184,6 +5232,14 @@
"html-to-text/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+ "jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+
+ "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
+ "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+
+ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -5264,10 +5320,6 @@
"react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
- "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
-
- "readable-web-to-node-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
-
"rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
@@ -5278,11 +5330,9 @@
"sim/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
- "stream-browserify/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
-
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+ "tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
From ca13cc9c138bf3d878181d275e707306a475a7af Mon Sep 17 00:00:00 2001
From: Waleed
Date: Mon, 27 Jul 2026 22:50:13 -0700
Subject: [PATCH 05/10] feat(api): add workflow export and import endpoints to
the public v1 API (#5999)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(api): add workflow export and import endpoints to the public v1 API
Adds GET /api/v1/workflows/[id]/export and POST /api/v1/workflows/import.
The export envelope is accepted verbatim by import, so workflows round-trip
between workspaces over the public API.
Unlike the admin export, the public export is secret-sanitized: stored
credentials and password fields are redacted while {{ENV_VAR}} references
and block positions are preserved. Import regenerates block, edge, loop and
parallel ids and de-duplicates the workflow name against the target folder.
Also moves parseWorkflowVariables out of the admin types module into
lib/workflows/variables/parse.ts so the public route does not import from
the admin namespace.
* fix(api): make workflow import atomic and clarify what export redacts
Writes the imported graph and its variables in a single transaction and
deletes the shell workflow row on any failure, so a caller that receives an
error is never left with a partially imported workflow. Previously a throw
from the variables update returned 500 while leaving the workflow behind
with an empty variables map.
Also narrows the export route's sanitization claim: workflow variables are
emitted as stored, matching GET /api/v1/workflows/[id] and the in-app
export. They are plaintext configuration readable at the same permission
level this route requires; secrets belong in environment variables, which
travel as unresolved references.
* fix(api): close import defects found in audit and share one write pipeline
Security:
- Escape block names before interpolating them into a RegExp in
updateValueReferences. Names reach it straight from imported workflow JSON
and normalizeWorkflowBlockName preserves regex metacharacters, so a name
like `a*a*a*a*b` compiled to a catastrophically backtracking pattern. A
sub-kilobyte body blocked the event loop for 50s and grew exponentially.
Also skip rename-to-itself, which is the entire map on the import path, so
the scan no longer runs at all there.
- Validate folder ownership before folder lock state, so a locked folder in
another workspace can no longer be distinguished from a missing one.
Correctness:
- Gate the imported graph on workflowStateSchema, the same schema the
canonical PUT /api/workflows/[id]/state path enforces. Without it a valid
201 could persist a block field of the wrong type, which then threw on
every subsequent read and left a workflow nothing could open.
- Guard the compensating delete so a failed rollback logs the orphaned id
instead of vanishing into a generic 500.
- Validate variable `type` against the enum and build the record on a
null-prototype object, so a `__proto__` key no longer silently drops the
variable.
- Bound payload-derived names and descriptions to the same limits the
contract declares for the explicit overrides.
- Return the description as stored rather than coercing '' to null, matching
GET /api/v1/workflows/[id].
Shared code, so the two write paths cannot drift:
- Extract prepareWorkflowStateForPersistence and use it from both
PUT /api/workflows/[id]/state and the v1 import route: agent-tool
sanitization, block backfill, dangling-edge removal, and loop/parallel
recomputation now have one implementation.
- Persist inline custom tools on import, which the canonical path already did.
- Move variable normalization into lib/workflows/variables and repoint the
admin importer at it, removing the last duplicate.
Docs:
- OpenAPI: oneOf -> anyOf on the import body. WorkflowExport matches any
object, so every valid object payload matched two branches and failed
validation under any spec-driven validator. Document 423 and the loss of
workspace-scoped bindings on export.
Tests: prepare-state unit tests and a real export -> import round trip with
no mocks of the sanitizer or parser, covering loop/parallel children and the
regex-metacharacter payload.
* fix(api): cap import names inside the bound and align the three import paths
- `truncate` appends its suffix after slicing, so capping at the contract
limit produced 203/2003-character values — past the very bound the cap
exists to enforce, and into the headroom reserved for dedup suffixes.
Reserve the ellipsis inside the limit.
- Match `extractWorkflowName`'s candidate order (state.metadata.name before
workflow.name) and trim, so the v1 API and the in-app importer resolve the
same name for the same payload. Previously a hand-authored payload carrying
both could yield two different names.
- Run the admin importer through prepareWorkflowStateForPersistence too. It
was writing raw parsed state, so a dangling edge tripped the workflow_edges
foreign key and a block missing its backfilled columns could land
unopenable — the same class this PR just closed on the v1 path.
---
.../(generated)/workflows/meta.json | 2 +
apps/docs/openapi.json | 388 ++++++++++++++
.../api/v1/admin/folders/[id]/export/route.ts | 7 +-
apps/sim/app/api/v1/admin/types.ts | 47 --
.../v1/admin/workflows/[id]/export/route.ts | 7 +-
.../api/v1/admin/workflows/export/route.ts | 7 +-
.../api/v1/admin/workflows/import/route.ts | 63 +--
.../v1/admin/workspaces/[id]/export/route.ts | 10 +-
apps/sim/app/api/v1/middleware.ts | 2 +
.../v1/workflows/[id]/export/route.test.ts | 224 +++++++++
.../app/api/v1/workflows/[id]/export/route.ts | 191 +++++++
.../app/api/v1/workflows/import/route.test.ts | 476 ++++++++++++++++++
apps/sim/app/api/v1/workflows/import/route.ts | 388 ++++++++++++++
.../sim/app/api/workflows/[id]/state/route.ts | 53 +-
apps/sim/lib/api/contracts/v1/workflows.ts | 145 +++++-
.../import-export-roundtrip.test.ts | 152 ++++++
.../persistence/prepare-state.test.ts | 101 ++++
.../workflows/persistence/prepare-state.ts | 71 +++
apps/sim/lib/workflows/variables/parse.ts | 114 +++++
apps/sim/stores/workflows/utils.ts | 19 +-
scripts/check-api-validation-contracts.ts | 4 +-
21 files changed, 2310 insertions(+), 161 deletions(-)
create mode 100644 apps/sim/app/api/v1/workflows/[id]/export/route.test.ts
create mode 100644 apps/sim/app/api/v1/workflows/[id]/export/route.ts
create mode 100644 apps/sim/app/api/v1/workflows/import/route.test.ts
create mode 100644 apps/sim/app/api/v1/workflows/import/route.ts
create mode 100644 apps/sim/lib/workflows/operations/import-export-roundtrip.test.ts
create mode 100644 apps/sim/lib/workflows/persistence/prepare-state.test.ts
create mode 100644 apps/sim/lib/workflows/persistence/prepare-state.ts
create mode 100644 apps/sim/lib/workflows/variables/parse.ts
diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json
index 491129e3cdf..8e2caa1abe8 100644
--- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json
+++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json
@@ -5,6 +5,8 @@
"cancelExecution",
"listWorkflows",
"getWorkflow",
+ "exportWorkflow",
+ "importWorkflow",
"deployWorkflow",
"undeployWorkflow",
"rollbackWorkflow",
diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json
index 49d6e7ed91b..1db7ab8e662 100644
--- a/apps/docs/openapi.json
+++ b/apps/docs/openapi.json
@@ -1035,6 +1035,160 @@
}
}
},
+ "/api/v1/workflows/import": {
+ "post": {
+ "operationId": "importWorkflow",
+ "summary": "Import Workflow",
+ "description": "Create a new workflow in a workspace from an export payload produced by GET /api/v1/workflows/{id}/export. Block, edge, loop and parallel identifiers are regenerated, so the same payload can be imported repeatedly and alongside its source workflow. The workflow name is de-duplicated against the target folder. Requires write permission on the target workspace.",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v1/workflows/import\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64\", \"workflow\": {\"version\": \"1.0\", \"state\": {\"blocks\": {}, \"edges\": []}}}'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The target workspace and the workflow payload to import.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "workflow"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace to import the workflow into. Requires write permission.",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "folderId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Optional folder to place the imported workflow in. Defaults to the workspace root.",
+ "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "Optional override for the imported workflow's name. Defaults to the name carried in the payload, then to \"Imported Workflow\".",
+ "example": "Customer Support Agent (copy)"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2000,
+ "description": "Optional override for the imported workflow's description. Defaults to the description carried in the payload.",
+ "example": "Imported from the staging workspace"
+ },
+ "workflow": {
+ "description": "The workflow to import. Accepts the export envelope returned by GET /api/v1/workflows/{id}/export, a bare workflow state object (`{ blocks, edges, ... }`), or a JSON string of either.",
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/WorkflowExport"
+ },
+ {
+ "type": "object",
+ "additionalProperties": true,
+ "minProperties": 1,
+ "description": "A bare workflow state object."
+ },
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "A JSON string of either accepted object form."
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The workflow was imported.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "description": "The newly created workflow.",
+ "$ref": "#/components/schemas/ImportedWorkflow"
+ },
+ "limits": {
+ "$ref": "#/components/schemas/Limits",
+ "description": "Rate limit and usage information for the current API key."
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "id": "7d2e9f14-3a6b-4c8d-b1e5-9f0a2c7d4e83",
+ "name": "Customer Support Agent (1)",
+ "description": "Routes incoming support tickets",
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "folderId": null,
+ "createdAt": "2026-06-20T14:15:22Z",
+ "updatedAt": "2026-06-20T14:15:22Z"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "413": {
+ "description": "The request body exceeds the 10 MB import limit.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "example": "Request body exceeds the maximum allowed size"
+ }
+ }
+ }
+ }
+ }
+ },
+ "423": {
+ "description": "The target folder is locked and cannot accept new workflows.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "example": "Folder is locked"
+ }
+ }
+ }
+ }
+ }
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ }
+ }
+ }
+ },
"/api/v1/workflows/{id}": {
"get": {
"operationId": "getWorkflow",
@@ -1104,6 +1258,92 @@
}
}
},
+ "/api/v1/workflows/{id}/export": {
+ "get": {
+ "operationId": "exportWorkflow",
+ "summary": "Export Workflow",
+ "description": "Export a workflow as a portable JSON envelope that POST /api/v1/workflows/import accepts verbatim. Credential and password field values stored on blocks are redacted from the payload, while `{{ENV_VAR}}` references and block positions are preserved. Workflow variables are returned as stored \u2014 they are plaintext configuration readable by anyone with workspace read, and secrets belong in environment variables, which travel as unresolved references. Requires read permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it. Workspace-scoped bindings (knowledge bases, workspace files, channels, projects, folders, MCP servers) are cleared, since those ids do not resolve in another workspace \u2014 an imported copy needs them re-selected.",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v1/workflows/{id}/export\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique workflow identifier.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The workflow export envelope.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "description": "The workflow export envelope.",
+ "$ref": "#/components/schemas/WorkflowExport"
+ },
+ "limits": {
+ "$ref": "#/components/schemas/Limits",
+ "description": "Rate limit and usage information for the current API key."
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "version": "1.0",
+ "exportedAt": "2026-06-20T14:15:22Z",
+ "workflow": {
+ "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
+ "name": "Customer Support Agent",
+ "description": "Routes incoming support tickets",
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "folderId": null
+ },
+ "state": {
+ "blocks": {},
+ "edges": [],
+ "loops": {},
+ "parallels": {},
+ "metadata": {
+ "name": "Customer Support Agent",
+ "description": "Routes incoming support tickets",
+ "exportedAt": "2026-06-20T14:15:22Z"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ }
+ }
+ }
+ },
"/api/v1/workflows/{id}/deploy": {
"post": {
"operationId": "deployWorkflow",
@@ -7299,6 +7539,154 @@
}
}
}
+ },
+ "WorkflowExportState": {
+ "type": "object",
+ "description": "The workflow graph. Block, edge, loop and parallel identifiers are regenerated on import, so a payload can be imported repeatedly without colliding with its source workflow.",
+ "properties": {
+ "blocks": {
+ "type": "object",
+ "description": "Map of block id to block definition, including canvas position and sub-block values.",
+ "additionalProperties": true,
+ "example": {}
+ },
+ "edges": {
+ "type": "array",
+ "description": "Connections between blocks.",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "example": []
+ },
+ "loops": {
+ "type": "object",
+ "description": "Loop container definitions, keyed by loop id.",
+ "additionalProperties": true,
+ "example": {}
+ },
+ "parallels": {
+ "type": "object",
+ "description": "Parallel container definitions, keyed by parallel id.",
+ "additionalProperties": true,
+ "example": {}
+ },
+ "metadata": {
+ "type": "object",
+ "description": "The source workflow's name, description and export timestamp.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "example": "Customer Support Agent"
+ },
+ "description": {
+ "type": "string",
+ "example": "Routes incoming support tickets"
+ },
+ "exportedAt": {
+ "type": "string",
+ "format": "date-time",
+ "example": "2026-06-20T14:15:22Z"
+ }
+ }
+ },
+ "variables": {
+ "type": "object",
+ "description": "Workflow-level variables, keyed by variable id. Emitted as stored \u2014 treat as plaintext configuration, not a secret store.",
+ "additionalProperties": true,
+ "example": {}
+ }
+ }
+ },
+ "WorkflowExport": {
+ "type": "object",
+ "description": "A portable workflow export envelope. Pass this object straight back to POST /api/v1/workflows/import to recreate the workflow. Credential and password field values stored on blocks are redacted; `{{ENV_VAR}}` references and workflow variables are preserved as stored.",
+ "properties": {
+ "version": {
+ "type": "string",
+ "description": "Export format version.",
+ "enum": ["1.0"],
+ "example": "1.0"
+ },
+ "exportedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp of when the export was generated.",
+ "example": "2026-06-20T14:15:22Z"
+ },
+ "workflow": {
+ "type": "object",
+ "description": "Identity of the exported workflow at the time of export.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "name": {
+ "type": "string",
+ "example": "Customer Support Agent"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true,
+ "example": "Routes incoming support tickets"
+ },
+ "workspaceId": {
+ "type": "string",
+ "nullable": true,
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "folderId": {
+ "type": "string",
+ "nullable": true,
+ "example": null
+ }
+ }
+ },
+ "state": {
+ "$ref": "#/components/schemas/WorkflowExportState"
+ }
+ }
+ },
+ "ImportedWorkflow": {
+ "type": "object",
+ "description": "The workflow created by an import.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier of the newly created workflow.",
+ "example": "7d2e9f14-3a6b-4c8d-b1e5-9f0a2c7d4e83"
+ },
+ "name": {
+ "type": "string",
+ "description": "The workflow name after de-duplication against the target folder.",
+ "example": "Customer Support Agent (1)"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true,
+ "example": "Routes incoming support tickets"
+ },
+ "workspaceId": {
+ "type": "string",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "folderId": {
+ "type": "string",
+ "nullable": true,
+ "example": null
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "example": "2026-06-20T14:15:22Z"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "example": "2026-06-20T14:15:22Z"
+ }
+ }
}
},
"responses": {
diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts
index 6591ee39e19..ddec95e57d3 100644
--- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts
+++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts
@@ -21,6 +21,7 @@ import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
+import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'
import { encodeFilenameForHeader } from '@/app/api/files/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -28,11 +29,7 @@ import {
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
-import {
- type FolderExportPayload,
- parseWorkflowVariables,
- type WorkflowExportState,
-} from '@/app/api/v1/admin/types'
+import type { FolderExportPayload, WorkflowExportState } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminFolderExportAPI')
diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts
index 4754de31054..77e5d7f6366 100644
--- a/apps/sim/app/api/v1/admin/types.ts
+++ b/apps/sim/app/api/v1/admin/types.ts
@@ -327,53 +327,6 @@ export interface WorkspaceImportResponse {
// Utility Functions
// =============================================================================
-/**
- * Parse workflow variables from database JSON format to Record format.
- * Handles both legacy Array and current Record formats.
- */
-export function parseWorkflowVariables(
- dbVariables: DbWorkflow['variables']
-): Record | undefined {
- if (!dbVariables) return undefined
-
- try {
- const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables
-
- // Handle legacy Array format by converting to Record
- if (Array.isArray(varsObj)) {
- const result: Record = {}
- for (const v of varsObj) {
- result[v.id] = {
- id: v.id,
- name: v.name,
- type: v.type,
- value: v.value,
- }
- }
- return result
- }
-
- // Already Record format - normalize and return
- if (typeof varsObj === 'object' && varsObj !== null) {
- const result: Record = {}
- for (const [key, v] of Object.entries(varsObj)) {
- const variable = v as { id: string; name: string; type: VariableType; value: unknown }
- result[key] = {
- id: variable.id,
- name: variable.name,
- type: variable.type,
- value: variable.value,
- }
- }
- return result
- }
- } catch {
- // pass
- }
-
- return undefined
-}
-
/**
* Extract workflow metadata from various export formats.
* Handles both full export payload and raw state formats.
diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts
index f5207ada0a7..cf321d1aeb3 100644
--- a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts
+++ b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts
@@ -14,17 +14,14 @@ import { adminV1ExportWorkflowContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
+import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
-import {
- parseWorkflowVariables,
- type WorkflowExportPayload,
- type WorkflowExportState,
-} from '@/app/api/v1/admin/types'
+import type { WorkflowExportPayload, WorkflowExportState } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowExportAPI')
diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts
index f3f90f968cd..ed5779f52bf 100644
--- a/apps/sim/app/api/v1/admin/workflows/export/route.ts
+++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts
@@ -26,17 +26,14 @@ import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
+import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
-import {
- parseWorkflowVariables,
- type WorkflowExportPayload,
- type WorkflowExportState,
-} from '@/app/api/v1/admin/types'
+import type { WorkflowExportPayload, WorkflowExportState } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowsExportAPI')
diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts
index b672f25b78f..502c2592b0e 100644
--- a/apps/sim/app/api/v1/admin/workflows/import/route.ts
+++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts
@@ -24,20 +24,17 @@ import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
+import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
+import { normalizeImportedVariables } from '@/lib/workflows/variables/parse'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
} from '@/app/api/v1/admin/responses'
-import {
- extractWorkflowMetadata,
- type VariableType,
- type WorkflowImportRequest,
- type WorkflowVariable,
-} from '@/app/api/v1/admin/types'
+import { extractWorkflowMetadata, type WorkflowImportRequest } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowImportAPI')
@@ -110,49 +107,29 @@ export const POST = withRouteHandler(
variables: {},
})
- const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
+ /**
+ * Same normalization the editor and the v1 import API run, via the one
+ * shared implementation — without it this route wrote raw parsed state,
+ * so a dangling edge tripped the `workflow_edges` foreign key and a block
+ * missing its backfilled columns could land unopenable.
+ */
+ const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(workflowData)
+ if (warnings.length > 0) {
+ logger.warn('Admin API: normalized imported workflow with warnings', { warnings })
+ }
+
+ const saveResult = await saveWorkflowToNormalizedTables(workflowId, {
+ ...workflowData,
+ ...preparedState,
+ })
if (!saveResult.success) {
await db.delete(workflow).where(eq(workflow.id, workflowId))
return internalErrorResponse(`Failed to save workflow state: ${saveResult.error}`)
}
- if (
- workflowData.variables &&
- typeof workflowData.variables === 'object' &&
- !Array.isArray(workflowData.variables)
- ) {
- const variablesRecord: Record = {}
- const vars = workflowData.variables as Record<
- string,
- { id?: string; name: string; type?: VariableType; value: unknown }
- >
- Object.entries(vars).forEach(([key, v]) => {
- const varId = v.id || key
- variablesRecord[varId] = {
- id: varId,
- name: v.name,
- type: v.type ?? 'string',
- value: v.value,
- }
- })
-
- await db
- .update(workflow)
- .set({ variables: variablesRecord, updatedAt: new Date() })
- .where(eq(workflow.id, workflowId))
- } else if (workflowData.variables && Array.isArray(workflowData.variables)) {
- const variablesRecord: Record = {}
- workflowData.variables.forEach((v) => {
- const varId = v.id || generateId()
- variablesRecord[varId] = {
- id: varId,
- name: v.name,
- type: (v.type as VariableType) ?? 'string',
- value: v.value,
- }
- })
-
+ const variablesRecord = normalizeImportedVariables(workflowData.variables)
+ if (Object.keys(variablesRecord).length > 0) {
await db
.update(workflow)
.set({ variables: variablesRecord, updatedAt: new Date() })
diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts
index e8913c74e0f..06204ab2898 100644
--- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts
+++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts
@@ -22,6 +22,7 @@ import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
+import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'
import { encodeFilenameForHeader } from '@/app/api/files/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -29,11 +30,10 @@ import {
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
-import {
- type FolderExportPayload,
- parseWorkflowVariables,
- type WorkflowExportState,
- type WorkspaceExportPayload,
+import type {
+ FolderExportPayload,
+ WorkflowExportState,
+ WorkspaceExportPayload,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceExportAPI')
diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts
index 61ec99a5abd..c9f757d91df 100644
--- a/apps/sim/app/api/v1/middleware.ts
+++ b/apps/sim/app/api/v1/middleware.ts
@@ -22,6 +22,8 @@ export type V1Endpoint =
| 'workflow-detail'
| 'workflow-deploy'
| 'workflow-rollback'
+ | 'workflow-export'
+ | 'workflow-import'
| 'audit-logs'
| 'tables'
| 'table-detail'
diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.test.ts
new file mode 100644
index 00000000000..01097f8b22b
--- /dev/null
+++ b/apps/sim/app/api/v1/workflows/[id]/export/route.test.ts
@@ -0,0 +1,224 @@
+/**
+ * @vitest-environment node
+ *
+ * Tests for GET /api/v1/workflows/[id]/export — verifies auth, workspace
+ * permission enforcement (masked as 404), payload shape, secret sanitization,
+ * and edge-handle normalization.
+ */
+
+import { createMockRequest, workflowAuthzMockFns } from '@sim/testing'
+import { NextResponse } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockValidateWorkspaceAccess,
+ mockLoadWorkflowFromNormalizedTables,
+ mockRecordAudit,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockValidateWorkspaceAccess: vi.fn(),
+ mockLoadWorkflowFromNormalizedTables: vi.fn(),
+ mockRecordAudit: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ createRateLimitResponse: vi.fn(() =>
+ NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ ),
+ validateWorkspaceAccess: mockValidateWorkspaceAccess,
+}))
+
+vi.mock('@/lib/workflows/persistence/utils', () => ({
+ loadWorkflowFromNormalizedTables: mockLoadWorkflowFromNormalizedTables,
+}))
+
+vi.mock('@/app/api/v1/logs/meta', () => ({
+ getUserLimits: vi.fn().mockResolvedValue({}),
+ createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
+}))
+
+vi.mock('@sim/audit', () => ({
+ recordAudit: mockRecordAudit,
+ AuditAction: { WORKFLOW_EXPORTED: 'workflow.exported' },
+ AuditResourceType: { WORKFLOW: 'workflow' },
+}))
+
+/**
+ * Overrides the global registry mock (whose blocks declare no subBlocks) so
+ * `sanitizeForExport` has a `password: true` field to actually redact.
+ */
+vi.mock('@/blocks/registry', () => ({
+ getBlock: vi.fn(() => ({
+ name: 'Starter',
+ description: 'Mock block',
+ icon: () => null,
+ subBlocks: [
+ { id: 'apiKey', type: 'short-input', password: true },
+ { id: 'endpoint', type: 'short-input' },
+ { id: 'secretFromEnv', type: 'short-input', password: true },
+ ],
+ outputs: {},
+ })),
+ getAllBlocks: vi.fn(() => []),
+ getLatestBlock: vi.fn(() => undefined),
+ getBlockByToolName: vi.fn(() => undefined),
+}))
+
+import { GET } from '@/app/api/v1/workflows/[id]/export/route'
+
+const WORKFLOW_ID = 'wf-1'
+
+const WORKFLOW_RECORD = {
+ id: WORKFLOW_ID,
+ name: 'My Workflow',
+ description: 'Does a thing',
+ workspaceId: 'ws-1',
+ folderId: 'folder-1',
+ variables: {
+ 'var-1': { id: 'var-1', name: 'apiHost', type: 'string', value: 'https://example.com' },
+ },
+}
+
+const NORMALIZED_STATE = {
+ blocks: {
+ 'block-1': {
+ id: 'block-1',
+ type: 'starter',
+ name: 'Start',
+ position: { x: 0, y: 0 },
+ subBlocks: {
+ apiKey: { id: 'apiKey', type: 'short-input', value: 'sk-super-secret' },
+ endpoint: { id: 'endpoint', type: 'short-input', value: 'https://api.example.com' },
+ secretFromEnv: { id: 'secretFromEnv', type: 'short-input', value: '{{MY_SECRET}}' },
+ },
+ outputs: {},
+ enabled: true,
+ },
+ },
+ edges: [
+ {
+ id: 'edge-1',
+ source: 'block-1',
+ target: 'block-2',
+ sourceHandle: null,
+ targetHandle: 'target',
+ },
+ ],
+ loops: {},
+ parallels: {},
+ isFromNormalizedTables: true,
+}
+
+function makeContext(id = WORKFLOW_ID) {
+ return { params: Promise.resolve({ id }) }
+}
+
+function makeRequest() {
+ return createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/export`
+ )
+}
+
+describe('GET /api/v1/workflows/[id]/export', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
+ mockValidateWorkspaceAccess.mockResolvedValue(null)
+ workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
+ mockLoadWorkflowFromNormalizedTables.mockResolvedValue(NORMALIZED_STATE)
+ })
+
+ it('returns 401 when the API key is rejected', async () => {
+ mockCheckRateLimit.mockResolvedValue({ allowed: false })
+
+ const response = await GET(makeRequest(), makeContext())
+
+ expect(response.status).toBe(401)
+ })
+
+ it('returns 404 when the workflow does not exist', async () => {
+ workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null)
+
+ const response = await GET(makeRequest(), makeContext())
+
+ expect(response.status).toBe(404)
+ })
+
+ it('masks a permission failure as 404 so callers cannot probe existence', async () => {
+ mockValidateWorkspaceAccess.mockResolvedValue(
+ NextResponse.json({ error: 'Access denied' }, { status: 403 })
+ )
+
+ const response = await GET(makeRequest(), makeContext())
+
+ expect(response.status).toBe(404)
+ await expect(response.json()).resolves.toEqual({ error: 'Workflow not found' })
+ })
+
+ it('returns 404 when the workflow row exists but its state does not', async () => {
+ mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null)
+
+ const response = await GET(makeRequest(), makeContext())
+
+ expect(response.status).toBe(404)
+ })
+
+ it('returns the export envelope with workflow metadata and state', async () => {
+ const response = await GET(makeRequest(), makeContext())
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.data.version).toBe('1.0')
+ expect(typeof body.data.exportedAt).toBe('string')
+ expect(body.data.workflow).toEqual({
+ id: WORKFLOW_ID,
+ name: 'My Workflow',
+ description: 'Does a thing',
+ workspaceId: 'ws-1',
+ folderId: 'folder-1',
+ })
+ expect(body.data.state.metadata).toMatchObject({
+ name: 'My Workflow',
+ description: 'Does a thing',
+ })
+ expect(Object.keys(body.data.state.blocks)).toEqual(['block-1'])
+ })
+
+ it('strips secret sub-block values but preserves env-var references', async () => {
+ const response = await GET(makeRequest(), makeContext())
+ const body = await response.json()
+
+ const subBlocks = body.data.state.blocks['block-1'].subBlocks
+ expect(JSON.stringify(body)).not.toContain('sk-super-secret')
+ expect(subBlocks.apiKey.value).toBeNull()
+ expect(subBlocks.endpoint.value).toBe('https://api.example.com')
+ expect(subBlocks.secretFromEnv.value).toBe('{{MY_SECRET}}')
+ })
+
+ it('normalizes null edge handles to omitted values', async () => {
+ const response = await GET(makeRequest(), makeContext())
+ const body = await response.json()
+
+ const [edge] = body.data.state.edges
+ expect(edge.sourceHandle).toBeUndefined()
+ expect(edge.targetHandle).toBe('target')
+ })
+
+ it('records a workflow-exported audit entry', async () => {
+ await GET(makeRequest(), makeContext())
+
+ expect(mockRecordAudit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'workflow.exported',
+ resourceId: WORKFLOW_ID,
+ workspaceId: 'ws-1',
+ actorId: 'user-1',
+ })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts
new file mode 100644
index 00000000000..b445e81a3f1
--- /dev/null
+++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts
@@ -0,0 +1,191 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { createLogger } from '@sim/logger'
+import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import type { Edge } from 'reactflow'
+import {
+ type V1WorkflowExportPayload,
+ v1ExportWorkflowContract,
+} from '@/lib/api/contracts/v1/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
+import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
+import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'
+import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
+import {
+ checkRateLimit,
+ createRateLimitResponse,
+ validateWorkspaceAccess,
+} from '@/app/api/v1/middleware'
+
+const logger = createLogger('V1WorkflowExportAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number]
+
+/**
+ * Projects a persisted ReactFlow edge onto the wire shape declared by the
+ * response contract. Field-by-field rather than a spread because ReactFlow's
+ * `Edge` is looser than the contract in three places: handles are `null` when
+ * unset (the contract and the importer both model absent as `undefined`),
+ * `label` is a `ReactNode`, and the marker fields accept an `EdgeMarker`
+ * object. Non-serializable values in those slots are dropped rather than
+ * emitted as `{}`.
+ */
+function toExportedEdge(edge: Edge): ExportedEdge {
+ return {
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: edge.sourceHandle ?? undefined,
+ targetHandle: edge.targetHandle ?? undefined,
+ type: edge.type,
+ animated: edge.animated,
+ style: edge.style as Record | undefined,
+ data: edge.data,
+ label: typeof edge.label === 'string' ? edge.label : undefined,
+ labelStyle: edge.labelStyle as Record | undefined,
+ labelShowBg: edge.labelShowBg,
+ labelBgStyle: edge.labelBgStyle as Record | undefined,
+ labelBgPadding: edge.labelBgPadding,
+ labelBgBorderRadius: edge.labelBgBorderRadius,
+ markerStart: typeof edge.markerStart === 'string' ? edge.markerStart : undefined,
+ markerEnd: typeof edge.markerEnd === 'string' ? edge.markerEnd : undefined,
+ }
+}
+
+/**
+ * GET /api/v1/workflows/[id]/export
+ *
+ * Exports a workflow as a portable JSON envelope that
+ * `POST /api/v1/workflows/import` accepts verbatim.
+ *
+ * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits
+ * the raw state for backup/restore, this surface runs the payload through
+ * `sanitizeForExport`, which nulls three classes of sub-block value:
+ * - `password: true` fields, unless the value is a whole `{{ENV_VAR}}`
+ * reference, which is preserved so the import resolves it in the target
+ * workspace;
+ * - `oauth-input` credentials;
+ * - **workspace-scoped bindings** — `knowledge-base-selector`, `file-selector`,
+ * `channel-selector`, `project-selector`, `folder-selector`,
+ * `mcp-server-selector` and friends, plus fields keyed `knowledgeBaseId`,
+ * `fileId`, `channelId`, `projectId`, `documentId`, `tagFilters`. These point
+ * at rows that do not exist in another workspace, so they are cleared rather
+ * than carried across as dangling ids.
+ *
+ * The last class means an export is **not** a byte-for-byte clone even when
+ * re-imported into the same workspace: those bindings come back empty and must
+ * be re-selected. This matches the in-app export and is documented on the
+ * public endpoint so callers do not expect otherwise.
+ *
+ * Workflow **variables** are emitted as stored, matching `GET
+ * /api/v1/workflows/[id]` and the in-app export. Variables are plaintext
+ * workflow configuration readable by anyone with workspace read (the same
+ * permission this route requires); secrets belong in environment variables,
+ * which travel as unresolved `{{ENV_VAR}}` references. Redacting them here
+ * would break import round-trips without narrowing access.
+ */
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-export')
+ if (!rateLimit.allowed) {
+ return createRateLimitResponse(rateLimit)
+ }
+
+ const userId = rateLimit.userId!
+ const parsed = await parseRequest(v1ExportWorkflowContract, request, context, {
+ validationErrorResponse: () =>
+ NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }),
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+
+ logger.info(`[${requestId}] Exporting workflow ${id}`, { userId })
+
+ const workflowData = await getActiveWorkflowRecord(id)
+ if (!workflowData?.workspaceId) {
+ return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
+ }
+
+ const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowData.workspaceId)
+ if (accessError) {
+ return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
+ }
+
+ const normalizedData = await loadWorkflowFromNormalizedTables(id)
+ if (!normalizedData) {
+ return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 })
+ }
+
+ const sanitized = sanitizeForExport({
+ blocks: normalizedData.blocks,
+ edges: normalizedData.edges,
+ loops: normalizedData.loops,
+ parallels: normalizedData.parallels,
+ metadata: {
+ name: workflowData.name,
+ description: workflowData.description ?? undefined,
+ },
+ variables: parseWorkflowVariables(workflowData.variables),
+ })
+
+ const payload: V1WorkflowExportPayload = {
+ version: '1.0',
+ exportedAt: sanitized.exportedAt,
+ workflow: {
+ id: workflowData.id,
+ name: workflowData.name,
+ description: workflowData.description,
+ workspaceId: workflowData.workspaceId,
+ folderId: workflowData.folderId,
+ },
+ state: {
+ ...sanitized.state,
+ edges: sanitized.state.edges.map(toExportedEdge),
+ metadata: {
+ ...sanitized.state.metadata,
+ name: workflowData.name,
+ description: workflowData.description ?? undefined,
+ exportedAt: sanitized.exportedAt,
+ },
+ },
+ }
+
+ recordAudit({
+ workspaceId: workflowData.workspaceId,
+ actorId: userId,
+ action: AuditAction.WORKFLOW_EXPORTED,
+ resourceType: AuditResourceType.WORKFLOW,
+ resourceId: workflowData.id,
+ resourceName: workflowData.name,
+ description: `Exported workflow "${workflowData.name}" via the API`,
+ metadata: {
+ workspaceId: workflowData.workspaceId,
+ folderId: workflowData.folderId || undefined,
+ blocksCount: Object.keys(payload.state.blocks).length,
+ edgesCount: payload.state.edges.length,
+ },
+ request,
+ })
+
+ const limits = await getUserLimits(userId)
+ const apiResponse = createApiResponse({ data: payload }, limits, rateLimit)
+
+ return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
+ } catch (error: unknown) {
+ const message = getErrorMessage(error, 'Unknown error')
+ logger.error(`[${requestId}] Workflow export error`, { error: message })
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+ }
+)
diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts
new file mode 100644
index 00000000000..f35a471f802
--- /dev/null
+++ b/apps/sim/app/api/v1/workflows/import/route.test.ts
@@ -0,0 +1,476 @@
+/**
+ * @vitest-environment node
+ *
+ * Tests for POST /api/v1/workflows/import — verifies auth, write-permission
+ * enforcement, payload-shape tolerance (export envelope / bare state / JSON
+ * string), metadata resolution, variable persistence, and rollback when
+ * persisting the imported state fails.
+ */
+
+import { createMockRequest } from '@sim/testing'
+import { NextResponse } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockValidateWorkspaceAccess,
+ mockPerformCreateWorkflow,
+ mockSaveWorkflowToNormalizedTables,
+ mockParseWorkflowJson,
+ mockAssertFolderMutable,
+ mockAssertFolderInWorkspace,
+ mockExtractAndPersistCustomTools,
+ mockPrepareWorkflowState,
+ mockDbDelete,
+ mockDbUpdate,
+ mockWorkspaceRows,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockValidateWorkspaceAccess: vi.fn(),
+ mockPerformCreateWorkflow: vi.fn(),
+ mockSaveWorkflowToNormalizedTables: vi.fn(),
+ mockParseWorkflowJson: vi.fn(),
+ mockAssertFolderMutable: vi.fn(),
+ mockAssertFolderInWorkspace: vi.fn(),
+ mockExtractAndPersistCustomTools: vi.fn(),
+ mockPrepareWorkflowState: vi.fn(),
+ mockDbDelete: vi.fn(),
+ mockDbUpdate: vi.fn(),
+ mockWorkspaceRows: { value: [{ id: 'ws-1' }] as Array<{ id: string }> },
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ createRateLimitResponse: vi.fn(() =>
+ NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ ),
+ validateWorkspaceAccess: mockValidateWorkspaceAccess,
+}))
+
+vi.mock('@/lib/workflows/orchestration', () => ({
+ performCreateWorkflow: mockPerformCreateWorkflow,
+}))
+
+vi.mock('@/lib/workflows/persistence/utils', () => ({
+ saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables,
+}))
+
+vi.mock('@/lib/workflows/operations/import-export', () => ({
+ parseWorkflowJson: mockParseWorkflowJson,
+}))
+
+vi.mock('@/app/api/v1/logs/meta', () => ({
+ getUserLimits: vi.fn().mockResolvedValue({}),
+ createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
+}))
+
+vi.mock('@sim/platform-authz/workflow', () => ({
+ assertFolderInWorkspace: mockAssertFolderInWorkspace,
+ assertFolderMutable: mockAssertFolderMutable,
+ FolderLockedError: class FolderLockedError extends Error {
+ status = 423
+ },
+ FolderNotFoundError: class FolderNotFoundError extends Error {
+ status = 400
+ },
+}))
+
+vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({
+ extractAndPersistCustomTools: mockExtractAndPersistCustomTools,
+}))
+
+/**
+ * Mocked to keep the block registry (and its icon/CSS graph) out of this
+ * suite. The real normalization is covered by `prepare-state.test.ts` and by
+ * the end-to-end round trip in `import-export-roundtrip.test.ts`.
+ */
+vi.mock('@/lib/workflows/persistence/prepare-state', () => ({
+ prepareWorkflowStateForPersistence: mockPrepareWorkflowState,
+}))
+
+vi.mock('@sim/db', () => ({
+ db: {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ limit: vi.fn(async () => mockWorkspaceRows.value),
+ })),
+ })),
+ })),
+ delete: mockDbDelete,
+ transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn({ update: mockDbUpdate })),
+ },
+}))
+
+import { POST } from '@/app/api/v1/workflows/import/route'
+
+const WORKSPACE_ID = 'ws-1'
+const CREATED_AT = new Date('2026-07-01T00:00:00Z')
+
+/** A schema-valid block: the route now gates on `workflowStateSchema`. */
+const VALID_BLOCK = {
+ id: 'block-1',
+ type: 'starter',
+ name: 'Start',
+ position: { x: 0, y: 0 },
+ subBlocks: {},
+ outputs: {},
+ enabled: true,
+}
+
+const PARSED_STATE = {
+ blocks: { 'block-1': VALID_BLOCK },
+ edges: [],
+ loops: {},
+ parallels: {},
+ variables: undefined as unknown,
+}
+
+const EXPORT_ENVELOPE = {
+ version: '1.0',
+ exportedAt: '2026-07-01T00:00:00.000Z',
+ workflow: {
+ id: 'wf-source',
+ name: 'Payload Name',
+ description: 'Payload description',
+ workspaceId: 'ws-other',
+ folderId: null,
+ },
+ state: { blocks: PARSED_STATE.blocks, edges: [], loops: {}, parallels: {} },
+}
+
+function makeRequest(body: unknown) {
+ return createMockRequest('POST', body, {}, 'http://localhost:3000/api/v1/workflows/import')
+}
+
+function validBody(overrides: Record = {}) {
+ return { workspaceId: WORKSPACE_ID, workflow: EXPORT_ENVELOPE, ...overrides }
+}
+
+describe('POST /api/v1/workflows/import', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockWorkspaceRows.value = [{ id: WORKSPACE_ID }]
+ mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
+ mockValidateWorkspaceAccess.mockResolvedValue(null)
+ mockAssertFolderMutable.mockResolvedValue(undefined)
+ mockAssertFolderInWorkspace.mockResolvedValue(undefined)
+ mockExtractAndPersistCustomTools.mockResolvedValue({ saved: 0, errors: [] })
+ mockPrepareWorkflowState.mockImplementation((state: { blocks: unknown; edges: unknown }) => ({
+ state: { blocks: state.blocks, edges: state.edges, loops: {}, parallels: {} },
+ warnings: [],
+ }))
+ mockParseWorkflowJson.mockReturnValue({ data: { ...PARSED_STATE }, errors: [] })
+ mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
+ mockPerformCreateWorkflow.mockResolvedValue({
+ success: true,
+ workflow: {
+ id: 'wf-new',
+ name: 'Payload Name',
+ description: 'Payload description',
+ workspaceId: WORKSPACE_ID,
+ folderId: null,
+ createdAt: CREATED_AT,
+ updatedAt: CREATED_AT,
+ },
+ })
+ mockDbDelete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
+ mockDbUpdate.mockReturnValue({
+ set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
+ })
+ })
+
+ it('returns 401 when the API key is rejected', async () => {
+ mockCheckRateLimit.mockResolvedValue({ allowed: false })
+
+ const response = await POST(makeRequest(validBody()))
+
+ expect(response.status).toBe(401)
+ })
+
+ it('rejects a body without workspaceId', async () => {
+ const response = await POST(makeRequest({ workflow: EXPORT_ENVELOPE }))
+
+ expect(response.status).toBe(400)
+ })
+
+ it('rejects a body without a workflow payload', async () => {
+ const response = await POST(makeRequest({ workspaceId: WORKSPACE_ID }))
+
+ expect(response.status).toBe(400)
+ })
+
+ it('requires write permission on the target workspace', async () => {
+ mockValidateWorkspaceAccess.mockResolvedValue(
+ NextResponse.json({ error: 'Access denied' }, { status: 403 })
+ )
+
+ const response = await POST(makeRequest(validBody()))
+
+ expect(response.status).toBe(403)
+ expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith(
+ expect.anything(),
+ 'user-1',
+ WORKSPACE_ID,
+ 'write'
+ )
+ expect(mockPerformCreateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('authorizes before touching the payload', async () => {
+ mockValidateWorkspaceAccess.mockResolvedValue(
+ NextResponse.json({ error: 'Access denied' }, { status: 403 })
+ )
+
+ await POST(makeRequest(validBody()))
+
+ expect(mockParseWorkflowJson).not.toHaveBeenCalled()
+ })
+
+ it('returns 404 when the target workspace does not exist', async () => {
+ mockWorkspaceRows.value = []
+
+ const response = await POST(makeRequest(validBody()))
+
+ expect(response.status).toBe(404)
+ })
+
+ it('returns 400 with parser errors for an invalid workflow payload', async () => {
+ mockParseWorkflowJson.mockReturnValue({
+ data: null,
+ errors: ['Missing or invalid field: blocks'],
+ })
+
+ const response = await POST(makeRequest(validBody()))
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({
+ error: 'Invalid workflow: Missing or invalid field: blocks',
+ })
+ })
+
+ it('creates the workflow and returns 201 with the resource shape', async () => {
+ const response = await POST(makeRequest(validBody()))
+ const body = await response.json()
+
+ expect(response.status).toBe(201)
+ expect(body.data).toEqual({
+ id: 'wf-new',
+ name: 'Payload Name',
+ description: 'Payload description',
+ workspaceId: WORKSPACE_ID,
+ folderId: null,
+ createdAt: CREATED_AT.toISOString(),
+ updatedAt: CREATED_AT.toISOString(),
+ })
+ expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith(
+ 'wf-new',
+ expect.anything(),
+ expect.anything()
+ )
+ })
+
+ it('derives the name from the export envelope and deduplicates it', async () => {
+ await POST(makeRequest(validBody()))
+
+ expect(mockPerformCreateWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Payload Name',
+ description: 'Payload description',
+ workspaceId: WORKSPACE_ID,
+ deduplicate: true,
+ userId: 'user-1',
+ })
+ )
+ })
+
+ it('prefers the explicit name and description overrides', async () => {
+ await POST(makeRequest(validBody({ name: 'Override', description: 'Override desc' })))
+
+ expect(mockPerformCreateWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Override', description: 'Override desc' })
+ )
+ })
+
+ it('falls back to a default name when the payload carries no metadata', async () => {
+ await POST(
+ makeRequest({
+ workspaceId: WORKSPACE_ID,
+ workflow: { blocks: PARSED_STATE.blocks, edges: [] },
+ })
+ )
+
+ expect(mockPerformCreateWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Imported Workflow', description: '' })
+ )
+ })
+
+ it('accepts a JSON string payload', async () => {
+ const response = await POST(
+ makeRequest(validBody({ workflow: JSON.stringify(EXPORT_ENVELOPE) }))
+ )
+
+ expect(response.status).toBe(201)
+ expect(mockParseWorkflowJson).toHaveBeenCalledWith(JSON.stringify(EXPORT_ENVELOPE))
+ expect(mockPerformCreateWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Payload Name' })
+ )
+ })
+
+ it('persists variables from the parsed state', async () => {
+ mockParseWorkflowJson.mockReturnValue({
+ data: {
+ ...PARSED_STATE,
+ variables: { 'var-1': { id: 'var-1', name: 'host', type: 'string', value: 'x' } },
+ },
+ errors: [],
+ })
+
+ await POST(makeRequest(validBody()))
+
+ expect(mockDbUpdate).toHaveBeenCalled()
+ })
+
+ it('normalizes legacy array-form variables into a keyed record', async () => {
+ const setSpy = vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) }))
+ mockDbUpdate.mockReturnValue({ set: setSpy })
+ mockParseWorkflowJson.mockReturnValue({
+ data: {
+ ...PARSED_STATE,
+ variables: [{ id: 'var-1', name: 'host', type: 'string', value: 'x' }],
+ },
+ errors: [],
+ })
+
+ await POST(makeRequest(validBody()))
+
+ expect(setSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variables: { 'var-1': { id: 'var-1', name: 'host', type: 'string', value: 'x' } },
+ })
+ )
+ })
+
+ it('skips the variables write when the payload has none', async () => {
+ await POST(makeRequest(validBody()))
+
+ expect(mockDbUpdate).not.toHaveBeenCalled()
+ })
+
+ it('writes the graph and variables inside a single transaction', async () => {
+ mockParseWorkflowJson.mockReturnValue({
+ data: {
+ ...PARSED_STATE,
+ variables: { 'var-1': { id: 'var-1', name: 'host', type: 'string', value: 'x' } },
+ },
+ errors: [],
+ })
+
+ await POST(makeRequest(validBody()))
+
+ const tx = { update: mockDbUpdate }
+ expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith('wf-new', expect.anything(), tx)
+ expect(mockDbUpdate).toHaveBeenCalled()
+ })
+
+ it('deletes the created row and returns 500 when persisting state fails', async () => {
+ const whereSpy = vi.fn().mockResolvedValue(undefined)
+ mockDbDelete.mockReturnValue({ where: whereSpy })
+ mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: false, error: 'boom' })
+
+ const response = await POST(makeRequest(validBody()))
+
+ expect(response.status).toBe(500)
+ expect(mockDbDelete).toHaveBeenCalled()
+ expect(whereSpy).toHaveBeenCalled()
+ })
+
+ it('rolls back the created workflow when the variables write throws', async () => {
+ const whereSpy = vi.fn().mockResolvedValue(undefined)
+ mockDbDelete.mockReturnValue({ where: whereSpy })
+ mockDbUpdate.mockImplementation(() => {
+ throw new Error('variables write failed')
+ })
+ mockParseWorkflowJson.mockReturnValue({
+ data: {
+ ...PARSED_STATE,
+ variables: { 'var-1': { id: 'var-1', name: 'host', type: 'string', value: 'x' } },
+ },
+ errors: [],
+ })
+
+ const response = await POST(makeRequest(validBody()))
+
+ expect(response.status).toBe(500)
+ expect(mockDbDelete).toHaveBeenCalled()
+ expect(whereSpy).toHaveBeenCalled()
+ })
+
+ it('caps a payload-derived name at the declared bound, ellipsis included', async () => {
+ await POST(
+ makeRequest(
+ validBody({
+ workflow: { ...EXPORT_ENVELOPE, workflow: { name: 'N'.repeat(500) } },
+ })
+ )
+ )
+
+ const { name } = mockPerformCreateWorkflow.mock.calls[0][0]
+ expect(name.length).toBeLessThanOrEqual(200)
+ expect(name.endsWith('...')).toBe(true)
+ })
+
+ it('prefers state.metadata.name, matching the in-app importer', async () => {
+ await POST(
+ makeRequest(
+ validBody({
+ workflow: {
+ workflow: { name: 'FromWorkflow' },
+ state: { ...EXPORT_ENVELOPE.state, metadata: { name: 'FromStateMetadata' } },
+ },
+ })
+ )
+ )
+
+ expect(mockPerformCreateWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'FromStateMetadata' })
+ )
+ })
+
+ it('unwraps an export response envelope so its metadata still resolves', async () => {
+ await POST(makeRequest(validBody({ workflow: { data: EXPORT_ENVELOPE, limits: {} } })))
+
+ expect(mockPerformCreateWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Payload Name', description: 'Payload description' })
+ )
+ })
+
+ /**
+ * Unreachable in practice — the route always passes `deduplicate: true`, so
+ * the orchestrator renames instead of conflicting. Covers the defensive
+ * mapping of the orchestrator's documented result union.
+ */
+ it('maps a conflict result from the orchestrator to 409', async () => {
+ mockPerformCreateWorkflow.mockResolvedValue({
+ success: false,
+ error: 'A workflow named "Payload Name" already exists in this folder',
+ errorCode: 'conflict',
+ })
+
+ const response = await POST(makeRequest(validBody()))
+
+ expect(response.status).toBe(409)
+ })
+
+ it('maps an invalid target folder from the orchestrator to 400', async () => {
+ mockPerformCreateWorkflow.mockResolvedValue({
+ success: false,
+ error: 'Target folder not found',
+ errorCode: 'validation',
+ })
+
+ const response = await POST(makeRequest(validBody({ folderId: 'folder-x' })))
+
+ expect(response.status).toBe(400)
+ })
+})
diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts
new file mode 100644
index 00000000000..037377ed063
--- /dev/null
+++ b/apps/sim/app/api/v1/workflows/import/route.ts
@@ -0,0 +1,388 @@
+import { db } from '@sim/db'
+import { workflow, workspace } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import {
+ assertFolderInWorkspace,
+ assertFolderMutable,
+ FolderLockedError,
+ FolderNotFoundError,
+} from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { truncate } from '@sim/utils/string'
+import { and, eq, isNull } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import {
+ V1_IMPORT_DESCRIPTION_MAX_LENGTH,
+ V1_IMPORT_NAME_MAX_LENGTH,
+ type V1ImportWorkflowData,
+ v1ImportWorkflowContract,
+} from '@/lib/api/contracts/v1/workflows'
+import { workflowStateSchema } from '@/lib/api/contracts/workflows'
+import { getValidationErrorMessage, parseRequest, serializeZodIssues } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
+import { performCreateWorkflow } from '@/lib/workflows/orchestration'
+import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
+import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
+import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
+import { normalizeImportedVariables } from '@/lib/workflows/variables/parse'
+import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
+import {
+ checkRateLimit,
+ createRateLimitResponse,
+ validateWorkspaceAccess,
+} from '@/app/api/v1/middleware'
+import type { WorkflowState } from '@/stores/workflows/workflow/types'
+
+const logger = createLogger('V1WorkflowImportAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/**
+ * Workflow JSON is a bounded document — a few hundred blocks at the outside.
+ * Capping well below the platform-wide `DEFAULT_MAX_JSON_BODY_BYTES` (50 MB)
+ * keeps a hostile caller from buffering a large body before validation runs.
+ */
+const MAX_IMPORT_BODY_BYTES = 10 * 1024 * 1024
+
+const DEFAULT_IMPORTED_WORKFLOW_NAME = 'Imported Workflow'
+
+const TRUNCATION_SUFFIX = '...'
+
+/**
+ * Caps a payload-derived string at `maxLength` *including* the ellipsis.
+ * `truncate` appends its suffix after slicing, so passing the limit straight
+ * through would yield `maxLength + 3` characters and overshoot the very bound
+ * this is enforcing.
+ */
+function capLength(value: string, maxLength: number): string {
+ return truncate(value, maxLength - TRUNCATION_SUFFIX.length, TRUNCATION_SUFFIX)
+}
+
+/**
+ * Reads a dot-delimited path off a parsed payload and returns it only when it
+ * is a non-empty string, so blank metadata falls through to the next candidate.
+ */
+function readString(source: unknown, path: string): string | undefined {
+ let current: unknown = source
+ for (const segment of path.split('.')) {
+ if (!current || typeof current !== 'object') return undefined
+ current = (current as Record)[segment]
+ }
+ return typeof current === 'string' && current.trim() ? current.trim() : undefined
+}
+
+/**
+ * Unwraps the `{ data: ... }` response envelope the export endpoint returns, so
+ * a caller can pipe an export response body straight into import.
+ * `parseWorkflowJson` already tolerates this shape when reading the graph;
+ * mirroring it here keeps metadata resolution from silently falling back to the
+ * default name for the same payload.
+ */
+function unwrapResponseEnvelope(payload: unknown): unknown {
+ if (!payload || typeof payload !== 'object') return payload
+ const inner = (payload as Record).data
+ if (!inner || typeof inner !== 'object') return payload
+ const candidate = inner as Record
+ return candidate.state || candidate.version || candidate.workflow ? candidate : payload
+}
+
+/**
+ * Resolves the imported workflow's name and description, preferring explicit
+ * request overrides and then the payload's own metadata. Accepts every shape
+ * the importer takes: the export envelope (`workflow.*`, `state.metadata.*`)
+ * and a bare state (`metadata.*`).
+ *
+ * Candidate order deliberately matches `extractWorkflowName` — the resolver the
+ * in-app importer has always used — so the same payload yields the same name on
+ * both surfaces. The in-app version additionally falls back to the uploaded
+ * filename, which has no analogue here; that is the only intended difference.
+ *
+ * Payload-derived values are capped at the same bounds the contract applies to
+ * the explicit overrides, otherwise the declared `maxLength` would not be the
+ * effective one — a caller could store an unbounded name simply by embedding it
+ * in the payload instead of passing it as a field.
+ */
+function resolveImportedMetadata(
+ rawPayload: unknown,
+ overrideName?: string,
+ overrideDescription?: string
+): { name: string; description: string } {
+ const payload = unwrapResponseEnvelope(rawPayload)
+
+ const name =
+ overrideName ||
+ capLength(
+ readString(payload, 'state.metadata.name') ||
+ readString(payload, 'workflow.name') ||
+ readString(payload, 'metadata.name') ||
+ DEFAULT_IMPORTED_WORKFLOW_NAME,
+ V1_IMPORT_NAME_MAX_LENGTH
+ )
+
+ const description =
+ overrideDescription ??
+ capLength(
+ readString(payload, 'state.metadata.description') ??
+ readString(payload, 'workflow.description') ??
+ readString(payload, 'metadata.description') ??
+ '',
+ V1_IMPORT_DESCRIPTION_MAX_LENGTH
+ )
+
+ return { name, description }
+}
+
+/**
+ * POST /api/v1/workflows/import
+ *
+ * Creates a new workflow in the target workspace from an export payload
+ * produced by `GET /api/v1/workflows/{id}/export`. Block, edge, loop and
+ * parallel ids are regenerated on import, so the same payload can be imported
+ * repeatedly and alongside its source workflow without collisions.
+ */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-import')
+ if (!rateLimit.allowed) {
+ return createRateLimitResponse(rateLimit)
+ }
+
+ const userId = rateLimit.userId!
+ const parsed = await parseRequest(
+ v1ImportWorkflowContract,
+ request,
+ {},
+ {
+ maxBodyBytes: MAX_IMPORT_BODY_BYTES,
+ validationErrorResponse: (error) =>
+ NextResponse.json(
+ {
+ error: getValidationErrorMessage(error, 'Invalid request body'),
+ details: error.issues,
+ },
+ { status: 400 }
+ ),
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const {
+ workspaceId,
+ folderId,
+ name: overrideName,
+ description: overrideDescription,
+ } = parsed.data.body
+
+ logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, {
+ userId,
+ folderId,
+ })
+
+ const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (accessError) return accessError
+
+ const [workspaceData] = await db
+ .select({ id: workspace.id })
+ .from(workspace)
+ .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
+ .limit(1)
+
+ if (!workspaceData) {
+ return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
+ }
+
+ /**
+ * Ownership before lock state: `assertFolderMutable` walks the folder's
+ * ancestor chain without filtering on workspace, so checking it first would
+ * let a caller distinguish a locked folder in someone else's workspace
+ * (423) from a nonexistent one (404).
+ */
+ if (folderId) {
+ await assertFolderInWorkspace(folderId, workspaceId)
+ }
+ await assertFolderMutable(folderId ?? null)
+
+ const rawWorkflow = parsed.data.body.workflow
+ const workflowContent =
+ typeof rawWorkflow === 'string' ? rawWorkflow : JSON.stringify(rawWorkflow)
+
+ const { data: parsedState, errors } = parseWorkflowJson(workflowContent)
+ if (!parsedState || errors.length > 0) {
+ return NextResponse.json({ error: `Invalid workflow: ${errors.join(', ')}` }, { status: 400 })
+ }
+
+ /**
+ * Variables are normalized before validation, not after: older exports
+ * carry them as an array, which is a shape `workflowStateSchema` rightly
+ * rejects but the importer has always accepted. Normalizing first keeps
+ * that tolerance while still validating what actually gets persisted.
+ */
+ const variables = normalizeImportedVariables(parsedState.variables)
+
+ /**
+ * `parseWorkflowJson` only checks that blocks/edges are structurally
+ * present. The normalized tables are read back through
+ * {@link workflowStateSchema}, and the client parses that response
+ * strictly — so a block field with the wrong type (`data.extent: 'child'`,
+ * `data.count: '5'`) would persist happily here and then throw on every
+ * subsequent load, leaving a workflow nothing can open. Gate on the same
+ * schema the canonical `PUT /api/workflows/[id]/state` path enforces.
+ */
+ const stateValidation = workflowStateSchema.safeParse({ ...parsedState, variables })
+ if (!stateValidation.success) {
+ const issue = stateValidation.error.issues[0]
+ const path = issue?.path.join('.')
+ return NextResponse.json(
+ {
+ error: `Invalid workflow state${path ? ` at ${path}` : ''}: ${issue?.message ?? 'validation failed'}`,
+ details: serializeZodIssues(stateValidation.error),
+ },
+ { status: 400 }
+ )
+ }
+
+ /**
+ * Same normalization the editor's `PUT /api/workflows/[id]/state` runs, via
+ * the one shared implementation — the two import surfaces must land
+ * byte-identical data for the same payload.
+ */
+ const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(parsedState)
+ if (warnings.length > 0) {
+ logger.warn(`[${requestId}] Normalized imported workflow with warnings`, { warnings })
+ }
+
+ const workflowState: WorkflowState = { ...parsedState, ...preparedState }
+
+ let parsedPayload: unknown = rawWorkflow
+ if (typeof rawWorkflow === 'string') {
+ try {
+ parsedPayload = JSON.parse(rawWorkflow)
+ } catch {
+ parsedPayload = undefined
+ }
+ }
+
+ const { name, description } = resolveImportedMetadata(
+ parsedPayload,
+ overrideName,
+ overrideDescription
+ )
+
+ const created = await performCreateWorkflow({
+ name,
+ description,
+ workspaceId,
+ folderId,
+ deduplicate: true,
+ userId,
+ requestId,
+ })
+
+ if (!created.success || !created.workflow) {
+ const status =
+ created.errorCode === 'conflict' ? 409 : created.errorCode === 'validation' ? 400 : 500
+ return NextResponse.json({ error: created.error }, { status })
+ }
+
+ const workflowId = created.workflow.id
+
+ /**
+ * The graph and the variables are written in one transaction so an import
+ * can never half-land, and any failure deletes the shell row created above
+ * — a caller that receives an error must not be left with a partially
+ * imported workflow in their workspace.
+ */
+ try {
+ await db.transaction(async (tx) => {
+ const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || 'Failed to save workflow state')
+ }
+
+ if (Object.keys(variables).length > 0) {
+ await tx
+ .update(workflow)
+ .set({ variables, updatedAt: new Date() })
+ .where(eq(workflow.id, workflowId))
+ }
+ })
+ } catch (error) {
+ logger.error(`[${requestId}] Failed to persist imported workflow, rolling back`, {
+ workflowId,
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ /**
+ * The rollback runs under the same conditions that just failed the write,
+ * so it can fail too. Losing it must not turn into an unlogged orphan:
+ * the caller still gets a 500, but the id is recorded loudly enough to
+ * clean up.
+ */
+ try {
+ await db.delete(workflow).where(eq(workflow.id, workflowId))
+ } catch (rollbackError) {
+ logger.error(
+ `[${requestId}] Rollback failed, workflow ${workflowId} is orphaned in workspace ${workspaceId}`,
+ { workflowId, workspaceId, error: getErrorMessage(rollbackError, 'Unknown error') }
+ )
+ }
+ return NextResponse.json({ error: 'Failed to save workflow state' }, { status: 500 })
+ }
+
+ /**
+ * Matches the canonical state-write path: agent blocks may carry inline
+ * custom-tool definitions that must exist as workspace rows to be
+ * resolvable at execution. Failures are logged, not fatal — the workflow
+ * itself imported successfully.
+ */
+ try {
+ const { saved, errors: toolErrors } = await extractAndPersistCustomTools(
+ workflowState,
+ workspaceId,
+ userId
+ )
+ if (saved > 0 || toolErrors.length > 0) {
+ logger.info(`[${requestId}] Persisted ${saved} custom tool(s) from import`, {
+ workflowId,
+ errors: toolErrors,
+ })
+ }
+ } catch (error) {
+ logger.error(`[${requestId}] Failed to persist custom tools from import`, {
+ workflowId,
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ }
+
+ logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, {
+ name: created.workflow.name,
+ blocksCount: Object.keys(workflowState.blocks).length,
+ })
+
+ const data: V1ImportWorkflowData = {
+ id: workflowId,
+ name: created.workflow.name,
+ description: created.workflow.description ?? null,
+ workspaceId,
+ folderId: created.workflow.folderId ?? null,
+ createdAt: created.workflow.createdAt.toISOString(),
+ updatedAt: created.workflow.updatedAt.toISOString(),
+ }
+
+ const limits = await getUserLimits(userId)
+ const apiResponse = createApiResponse({ data }, limits, rateLimit)
+
+ return NextResponse.json(apiResponse.body, { status: 201, headers: apiResponse.headers })
+ } catch (error: unknown) {
+ if (error instanceof FolderLockedError || error instanceof FolderNotFoundError) {
+ return NextResponse.json({ error: error.message }, { status: error.status })
+ }
+ const message = getErrorMessage(error, 'Unknown error')
+ logger.error(`[${requestId}] Workflow import error`, { error: message })
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+})
diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts
index fbd33b045b4..209cfd226e8 100644
--- a/apps/sim/app/api/workflows/[id]/state/route.ts
+++ b/apps/sim/app/api/workflows/[id]/state/route.ts
@@ -17,14 +17,12 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
+import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
-import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
-import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
-import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowStateAPI')
@@ -152,46 +150,14 @@ export const PUT = withRouteHandler(
// per variable (the path param is the source of truth), so the check
// is unreachable and was removed.
- // Sanitize custom tools in agent blocks before saving
- const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(
- state.blocks as Record
- )
-
- // Save to normalized tables
- // Ensure all required fields are present for WorkflowState type
- // Filter out blocks without type or name before saving
- const filteredBlocks = Object.entries(sanitizedBlocks).reduce(
- (acc, [blockId, block]: [string, BlockState]) => {
- if (block.type && block.name) {
- // Ensure all required fields are present
- acc[blockId] = {
- ...block,
- enabled: block.enabled !== undefined ? block.enabled : true,
- horizontalHandles:
- block.horizontalHandles !== undefined ? block.horizontalHandles : true,
- height: block.height !== undefined ? block.height : 0,
- subBlocks: block.subBlocks || {},
- outputs: block.outputs || {},
- }
- }
- return acc
- },
- {} as typeof state.blocks
- )
-
- const typedBlocks = filteredBlocks as Record
- const validatedEdges = validateEdges(state.edges as WorkflowState['edges'], typedBlocks)
- const validationWarnings = validatedEdges.dropped.map(
- ({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}`
- )
- const canonicalLoops = generateLoopBlocks(typedBlocks)
- const canonicalParallels = generateParallelBlocks(typedBlocks)
+ const { state: preparedState, warnings: preparationWarnings } =
+ prepareWorkflowStateForPersistence({
+ blocks: state.blocks as Record,
+ edges: state.edges as WorkflowState['edges'],
+ })
const workflowState = {
- blocks: filteredBlocks,
- edges: validatedEdges.valid,
- loops: canonicalLoops,
- parallels: canonicalParallels,
+ ...preparedState,
lastSaved: state.lastSaved || Date.now(),
isDeployed: state.isDeployed || false,
deployedAt: state.deployedAt,
@@ -303,10 +269,7 @@ export const PUT = withRouteHandler(
)
}
- return NextResponse.json(
- { success: true, warnings: [...warnings, ...validationWarnings] },
- { status: 200 }
- )
+ return NextResponse.json({ success: true, warnings: preparationWarnings }, { status: 200 })
} catch (error: any) {
if (error instanceof WorkflowLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts
index d0c1469e189..aa5a78c901d 100644
--- a/apps/sim/lib/api/contracts/v1/workflows.ts
+++ b/apps/sim/lib/api/contracts/v1/workflows.ts
@@ -4,9 +4,9 @@ import {
deploymentOperationSummarySchema,
deploymentVersionMetadataFieldsSchema,
} from '@/lib/api/contracts/deployments'
-import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives'
+import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
-import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows'
+import { workflowIdParamsSchema, workflowStateSchema } from '@/lib/api/contracts/workflows'
export const v1ListWorkflowsQuerySchema = z.object({
workspaceId: z.string().min(1),
@@ -158,3 +158,144 @@ export const v1RollbackWorkflowContract = defineRouteContract({
schema: withV1Limits(v1RollbackWorkflowDataSchema),
},
})
+
+/** Workflow variable as carried inside an export payload's `state.variables`. */
+const v1WorkflowExportVariableSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']),
+ value: z.unknown(),
+})
+
+/**
+ * Workflow state emitted by the public export endpoint.
+ *
+ * Structurally mirrors the admin export state (`adminWorkflowExportStateSchema`
+ * in `@/lib/api/contracts/admin`) so both payloads round-trip through the same
+ * importer. The surfaces are deliberately kept as separate schemas because the
+ * data differs: public exports are secret-sanitized via `sanitizeForExport`
+ * (credentials stripped, `{{ENV_VAR}}` references preserved), while admin
+ * exports are raw for backup/restore.
+ */
+export const v1WorkflowExportStateSchema = workflowStateSchema
+ /**
+ * `sanitizeForExport` builds its payload from `{blocks, edges, loops,
+ * parallels, metadata, variables}` only, so these three are structurally
+ * unreachable on this wire. `deployedAt` in particular is a `z.coerce.date()`
+ * whose output type is a `Date` — never a valid JSON value — so leaving it
+ * inherited would put an unrepresentable type in a response contract.
+ */
+ .omit({ lastSaved: true, isDeployed: true, deployedAt: true })
+ .extend({
+ metadata: z
+ .object({
+ name: z.string().optional(),
+ description: z.string().optional(),
+ sortOrder: z.number().optional(),
+ exportedAt: z.string().optional(),
+ })
+ .optional(),
+ variables: z.record(z.string(), v1WorkflowExportVariableSchema).optional(),
+ })
+
+export const v1WorkflowExportPayloadSchema = z.object({
+ version: z.literal('1.0'),
+ exportedAt: z.string(),
+ workflow: z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string().nullable(),
+ workspaceId: z.string().nullable(),
+ folderId: z.string().nullable(),
+ }),
+ state: v1WorkflowExportStateSchema,
+})
+
+export type V1WorkflowExportPayload = z.output
+
+export const v1ExportWorkflowContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/v1/workflows/[id]/export',
+ params: workflowIdParamsSchema,
+ response: {
+ mode: 'json',
+ schema: withV1Limits(v1WorkflowExportPayloadSchema),
+ },
+})
+
+/**
+ * Upper bound on an imported workflow name. `workflow.name` is an unbounded
+ * `text` column, so this is a product limit rather than a storage one — it
+ * keeps a name renderable in the sidebar and leaves headroom for the
+ * deduplication suffix appended on insert. Exported because the route applies
+ * the same bound to names read out of the payload, so the declared limit is
+ * also the effective one.
+ */
+export const V1_IMPORT_NAME_MAX_LENGTH = 200
+
+/** Upper bound on an imported workflow description. See above. */
+export const V1_IMPORT_DESCRIPTION_MAX_LENGTH = 2000
+
+/**
+ * Import request body. `workflow` accepts either the envelope emitted by
+ * {@link v1ExportWorkflowContract} (`{ version, exportedAt, workflow, state }`),
+ * a bare workflow state (`{ blocks, edges, ... }`), or a JSON string of either
+ * — the same three forms the admin importer accepts, so payloads are portable
+ * between the two surfaces. `name` and `description` override whatever the
+ * payload's own metadata carries.
+ */
+export const v1ImportWorkflowBodySchema = z.object({
+ workspaceId: workspaceIdSchema,
+ folderId: z.string().min(1, 'folderId cannot be empty').optional(),
+ name: z
+ .string()
+ .min(1, 'name cannot be empty')
+ .max(V1_IMPORT_NAME_MAX_LENGTH, `name must be at most ${V1_IMPORT_NAME_MAX_LENGTH} characters`)
+ .optional(),
+ description: z
+ .string()
+ .max(
+ V1_IMPORT_DESCRIPTION_MAX_LENGTH,
+ `description must be at most ${V1_IMPORT_DESCRIPTION_MAX_LENGTH} characters`
+ )
+ .optional(),
+ workflow: z.union(
+ [
+ z.string().min(1, 'workflow cannot be empty'),
+ z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, {
+ error: 'workflow cannot be empty',
+ }),
+ ],
+ { error: 'workflow must be a workflow export object or a JSON string' }
+ ),
+})
+
+export type V1ImportWorkflowBody = z.input
+
+/**
+ * Created workflow, shaped as the subset of the v1 workflow resource that is
+ * knowable at import time (an imported workflow is never deployed and has no
+ * run history), so callers can feed it straight back into
+ * `GET /api/v1/workflows/{id}`.
+ */
+export const v1ImportWorkflowDataSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string().nullable(),
+ workspaceId: z.string(),
+ folderId: z.string().nullable(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+})
+
+export type V1ImportWorkflowData = z.output
+
+export const v1ImportWorkflowContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/v1/workflows/import',
+ body: v1ImportWorkflowBodySchema,
+ response: {
+ mode: 'json',
+ schema: withV1Limits(v1ImportWorkflowDataSchema),
+ },
+})
diff --git a/apps/sim/lib/workflows/operations/import-export-roundtrip.test.ts b/apps/sim/lib/workflows/operations/import-export-roundtrip.test.ts
new file mode 100644
index 00000000000..3735775f21c
--- /dev/null
+++ b/apps/sim/lib/workflows/operations/import-export-roundtrip.test.ts
@@ -0,0 +1,152 @@
+/**
+ * @vitest-environment node
+ *
+ * End-to-end round trip through the REAL sanitizer and importer — no mocks of
+ * either — pinning the property the export/import API pair exists to provide:
+ * a workflow exported by `GET /api/v1/workflows/[id]/export` must re-import
+ * through `POST /api/v1/workflows/import` with its graph intact.
+ *
+ * Guards specifically against silent structural loss (child blocks inside loop
+ * and parallel containers, dangling references after id regeneration) that
+ * mock-heavy route tests cannot catch.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
+import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
+
+function makeSourceState() {
+ return {
+ blocks: {
+ starter: {
+ id: 'starter',
+ type: 'starter',
+ name: 'Start',
+ position: { x: 0, y: 0 },
+ subBlocks: { input: { id: 'input', type: 'short-input', value: 'hello' } },
+ outputs: {},
+ enabled: true,
+ },
+ loop1: {
+ id: 'loop1',
+ type: 'loop',
+ name: 'Loop 1',
+ position: { x: 200, y: 0 },
+ subBlocks: {},
+ outputs: {},
+ enabled: true,
+ data: { width: 500, height: 300, count: 3, loopType: 'for' },
+ },
+ childInLoop: {
+ id: 'childInLoop',
+ type: 'function',
+ name: 'Child',
+ position: { x: 50, y: 50 },
+ subBlocks: { code: { id: 'code', type: 'code', value: 'return 1' } },
+ outputs: {},
+ enabled: true,
+ data: { parentId: 'loop1', extent: 'parent' },
+ },
+ par1: {
+ id: 'par1',
+ type: 'parallel',
+ name: 'Parallel 1',
+ position: { x: 800, y: 0 },
+ subBlocks: {},
+ outputs: {},
+ enabled: true,
+ data: { width: 500, height: 300, count: 2, parallelType: 'count' },
+ },
+ childInPar: {
+ id: 'childInPar',
+ type: 'function',
+ name: 'Child P',
+ position: { x: 60, y: 60 },
+ subBlocks: {},
+ outputs: {},
+ enabled: true,
+ data: { parentId: 'par1', extent: 'parent' },
+ },
+ } as Record,
+ edges: [
+ { id: 'e1', source: 'starter', target: 'loop1', sourceHandle: 'source', targetHandle: null },
+ { id: 'e2', source: 'loop1', target: 'par1' },
+ ] as any[],
+ loops: {
+ loop1: { id: 'loop1', nodes: ['childInLoop'], iterations: 3, loopType: 'for' as const },
+ } as Record,
+ parallels: {
+ par1: { id: 'par1', nodes: ['childInPar'], count: 2, parallelType: 'count' as const },
+ } as Record,
+ variables: {
+ v1: { id: 'v1', name: 'apiHost', type: 'string' as const, value: 'https://example.com' },
+ },
+ metadata: { name: 'Round Trip', description: 'probe' },
+ }
+}
+
+describe('workflow export -> import round trip', () => {
+ const exported = sanitizeForExport(makeSourceState() as any)
+ const { data: reimported, errors } = parseWorkflowJson(JSON.stringify(exported))
+
+ it('re-imports without validation errors', () => {
+ expect(errors).toEqual([])
+ expect(reimported).not.toBeNull()
+ })
+
+ it('preserves every block, including children inside loop and parallel containers', () => {
+ expect(Object.keys(exported.state.blocks).sort()).toEqual([
+ 'childInLoop',
+ 'childInPar',
+ 'loop1',
+ 'par1',
+ 'starter',
+ ])
+ expect(Object.keys(reimported!.blocks)).toHaveLength(5)
+ })
+
+ it('preserves every edge', () => {
+ expect(reimported!.edges).toHaveLength(2)
+ })
+
+ it('leaves no dangling parent, edge, loop or parallel reference after id regeneration', () => {
+ const ids = new Set(Object.values(reimported!.blocks).map((b: any) => b.id))
+
+ for (const b of Object.values(reimported!.blocks) as any[]) {
+ if (b.data?.parentId) expect(ids.has(b.data.parentId)).toBe(true)
+ }
+ for (const e of reimported!.edges as any[]) {
+ expect(ids.has(e.source)).toBe(true)
+ expect(ids.has(e.target)).toBe(true)
+ }
+ for (const loop of Object.values(reimported!.loops ?? {}) as any[]) {
+ for (const n of loop.nodes ?? []) expect(ids.has(n)).toBe(true)
+ }
+ for (const p of Object.values(reimported!.parallels ?? {}) as any[]) {
+ for (const n of p.nodes ?? []) expect(ids.has(n)).toBe(true)
+ }
+ })
+
+ it('regenerates ids so a payload can be imported alongside its source', () => {
+ expect(Object.keys(reimported!.blocks)).not.toContain('starter')
+ })
+
+ it('preserves workflow variables', () => {
+ expect(reimported!.variables).toMatchObject({
+ v1: { id: 'v1', name: 'apiHost', type: 'string', value: 'https://example.com' },
+ })
+ })
+
+ it('does not hang on a block name containing regex metacharacters', () => {
+ const hostile = makeSourceState()
+ hostile.blocks.starter.name = 'a*a*a*a*a*a*a*a*b'
+ hostile.blocks.starter.subBlocks.input.value = `<${'a'.repeat(120)}`
+
+ const started = performance.now()
+ const { data } = parseWorkflowJson(JSON.stringify(sanitizeForExport(hostile as any)))
+ const elapsed = performance.now() - started
+
+ expect(data).not.toBeNull()
+ expect(elapsed).toBeLessThan(2000)
+ })
+})
diff --git a/apps/sim/lib/workflows/persistence/prepare-state.test.ts b/apps/sim/lib/workflows/persistence/prepare-state.test.ts
new file mode 100644
index 00000000000..98405fe82cf
--- /dev/null
+++ b/apps/sim/lib/workflows/persistence/prepare-state.test.ts
@@ -0,0 +1,101 @@
+/**
+ * @vitest-environment node
+ *
+ * Tests the single normalization pipeline shared by `PUT /api/workflows/[id]/state`
+ * and `POST /api/v1/workflows/import`. Both write paths must land identical data
+ * for identical input, so this is where that behavior is pinned.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
+import type { BlockState } from '@/stores/workflows/workflow/types'
+
+function block(overrides: Partial & { id: string }): BlockState {
+ return {
+ type: 'function',
+ name: `Block ${overrides.id}`,
+ position: { x: 0, y: 0 },
+ subBlocks: {},
+ outputs: {},
+ enabled: true,
+ ...overrides,
+ } as BlockState
+}
+
+describe('prepareWorkflowStateForPersistence', () => {
+ it('drops edges whose endpoints do not resolve', () => {
+ const { state, warnings } = prepareWorkflowStateForPersistence({
+ blocks: { a: block({ id: 'a' }), b: block({ id: 'b' }) },
+ edges: [
+ { id: 'good', source: 'a', target: 'b' },
+ { id: 'dangling', source: 'a', target: 'ghost' },
+ ] as never,
+ })
+
+ expect(state.edges.map((e) => e.id)).toEqual(['good'])
+ expect(warnings.some((w) => w.includes('dangling'))).toBe(true)
+ })
+
+ it('drops blocks missing type or name', () => {
+ const { state } = prepareWorkflowStateForPersistence({
+ blocks: {
+ ok: block({ id: 'ok' }),
+ noType: block({ id: 'noType', type: '' }),
+ noName: block({ id: 'noName', name: '' }),
+ },
+ edges: [] as never,
+ })
+
+ expect(Object.keys(state.blocks)).toEqual(['ok'])
+ })
+
+ it('backfills the columns the normalized tables require', () => {
+ const { state } = prepareWorkflowStateForPersistence({
+ blocks: {
+ a: {
+ id: 'a',
+ type: 'function',
+ name: 'A',
+ position: { x: 1, y: 2 },
+ } as unknown as BlockState,
+ },
+ edges: [] as never,
+ })
+
+ expect(state.blocks.a).toMatchObject({
+ enabled: true,
+ horizontalHandles: true,
+ height: 0,
+ subBlocks: {},
+ outputs: {},
+ })
+ })
+
+ it('recomputes loop and parallel containers from the blocks', () => {
+ const { state } = prepareWorkflowStateForPersistence({
+ blocks: {
+ loop1: block({ id: 'loop1', type: 'loop', data: { count: 3, loopType: 'for' } as never }),
+ child: block({ id: 'child', data: { parentId: 'loop1', extent: 'parent' } as never }),
+ par1: block({
+ id: 'par1',
+ type: 'parallel',
+ data: { count: 2, parallelType: 'count' } as never,
+ }),
+ childP: block({ id: 'childP', data: { parentId: 'par1', extent: 'parent' } as never }),
+ },
+ edges: [] as never,
+ })
+
+ expect(state.loops.loop1?.nodes).toEqual(['child'])
+ expect(state.parallels.par1?.nodes).toEqual(['childP'])
+ })
+
+ it('does not mutate the caller-supplied blocks', () => {
+ const blocks = { a: block({ id: 'a' }) }
+ const snapshot = structuredClone(blocks)
+
+ prepareWorkflowStateForPersistence({ blocks, edges: [] as never })
+
+ expect(blocks).toEqual(snapshot)
+ })
+})
diff --git a/apps/sim/lib/workflows/persistence/prepare-state.ts b/apps/sim/lib/workflows/persistence/prepare-state.ts
new file mode 100644
index 00000000000..e6d4ec74a86
--- /dev/null
+++ b/apps/sim/lib/workflows/persistence/prepare-state.ts
@@ -0,0 +1,71 @@
+import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
+import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
+import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
+import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
+
+export interface PreparedWorkflowState {
+ blocks: Record
+ edges: WorkflowState['edges']
+ loops: ReturnType
+ parallels: ReturnType
+}
+
+export interface PrepareWorkflowStateResult {
+ state: PreparedWorkflowState
+ /** Human-readable notes about what was dropped or rewritten, for logging. */
+ warnings: string[]
+}
+
+/**
+ * Normalizes a workflow graph into the exact shape the normalized tables expect,
+ * shared by every server-side write path so they cannot drift apart.
+ *
+ * Callers today: `PUT /api/workflows/[id]/state` (the editor and the in-app zip
+ * importer, which reaches it over HTTP) and `POST /api/v1/workflows/import`.
+ *
+ * The steps are order-dependent:
+ * 1. Strip secrets from inline agent-tool definitions.
+ * 2. Drop blocks missing `type`/`name` and backfill the columns the tables
+ * require, so a partial block cannot violate a NOT NULL constraint.
+ * 3. Drop edges whose endpoints no longer resolve — `workflow_edges` has
+ * foreign keys onto `workflow_blocks`, so a dangling edge would otherwise
+ * abort the whole transaction with an opaque database error.
+ * 4. Recompute loop and parallel containers from the surviving blocks, which
+ * are the canonical source for both.
+ */
+export function prepareWorkflowStateForPersistence(state: {
+ blocks: Record
+ edges: WorkflowState['edges']
+}): PrepareWorkflowStateResult {
+ const { blocks: sanitizedBlocks, warnings: sanitizationWarnings } = sanitizeAgentToolsInBlocks(
+ state.blocks
+ )
+
+ const blocks: Record = {}
+ for (const [blockId, block] of Object.entries(sanitizedBlocks)) {
+ if (!block.type || !block.name) continue
+ blocks[blockId] = {
+ ...block,
+ enabled: block.enabled !== undefined ? block.enabled : true,
+ horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true,
+ height: block.height !== undefined ? block.height : 0,
+ subBlocks: block.subBlocks || {},
+ outputs: block.outputs || {},
+ }
+ }
+
+ const validatedEdges = validateEdges(state.edges, blocks)
+
+ return {
+ state: {
+ blocks,
+ edges: validatedEdges.valid,
+ loops: generateLoopBlocks(blocks),
+ parallels: generateParallelBlocks(blocks),
+ },
+ warnings: [
+ ...sanitizationWarnings,
+ ...validatedEdges.dropped.map(({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}`),
+ ],
+ }
+}
diff --git a/apps/sim/lib/workflows/variables/parse.ts b/apps/sim/lib/workflows/variables/parse.ts
new file mode 100644
index 00000000000..58c5a41cfa2
--- /dev/null
+++ b/apps/sim/lib/workflows/variables/parse.ts
@@ -0,0 +1,114 @@
+import type { workflow } from '@sim/db/schema'
+import { generateId } from '@sim/utils/id'
+import type { Variable } from '@sim/workflow-types/workflow'
+import type { InferSelectModel } from 'drizzle-orm'
+
+type DbWorkflowVariables = InferSelectModel['variables']
+
+const VARIABLE_TYPES = new Set([
+ 'string',
+ 'number',
+ 'boolean',
+ 'object',
+ 'array',
+ 'plain',
+])
+
+/**
+ * Parses the persisted `workflow.variables` JSONB column into the canonical
+ * `Record` shape.
+ *
+ * Tolerates the three forms the column has carried over time: a JSON string, a
+ * legacy `Variable[]` array (re-keyed by variable id), and the current record.
+ * Returns `undefined` for null/unparseable values so callers can omit the field
+ * entirely rather than emitting an empty object.
+ */
+export function parseWorkflowVariables(
+ dbVariables: DbWorkflowVariables
+): Record | undefined {
+ if (!dbVariables) return undefined
+
+ try {
+ const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables
+
+ if (Array.isArray(varsObj)) {
+ const result: Record = {}
+ for (const v of varsObj) {
+ /**
+ * Legacy array rows without a usable id would otherwise key the record
+ * under the literal string "undefined" and emit `id: undefined`, which
+ * violates the export contract's `id: z.string()`.
+ */
+ if (!v || typeof v !== 'object' || typeof v.id !== 'string' || !v.id) continue
+ result[v.id] = {
+ id: v.id,
+ name: v.name,
+ type: v.type,
+ value: v.value,
+ }
+ }
+ return result
+ }
+
+ if (typeof varsObj === 'object' && varsObj !== null) {
+ const result: Record = {}
+ for (const [key, v] of Object.entries(varsObj)) {
+ const variable = v as Variable
+ result[key] = {
+ id: variable.id,
+ name: variable.name,
+ type: variable.type,
+ value: variable.value,
+ }
+ }
+ return result
+ }
+ } catch {
+ return undefined
+ }
+
+ return undefined
+}
+
+/**
+ * Normalizes variables arriving from an *untrusted* import payload into the
+ * persisted `Record` shape, keyed by variable id.
+ *
+ * Distinct from {@link parseWorkflowVariables}, which reads data this system
+ * already wrote: nothing here is assumed well-formed. Accepts both the record
+ * form and the legacy array form, and is built on a null-prototype object so a
+ * payload keyed `__proto__` is stored as an ordinary own key instead of hitting
+ * the `Object.prototype` setter, which would silently discard the variable. An
+ * unrecognized `type` falls back to `'string'` rather than being written
+ * through to the JSONB column verbatim.
+ */
+export function normalizeImportedVariables(variables: unknown): Record {
+ /**
+ * Assembled on a null-prototype object so a `__proto__` key lands as an
+ * ordinary own property instead of invoking the prototype setter, then
+ * spread back onto a plain object (spread defines, never assigns, so the key
+ * survives) — callers and JSON serialization get an ordinary object.
+ */
+ const record: Record = Object.create(null)
+ if (!variables || typeof variables !== 'object') return { ...record }
+
+ const entries: Array<[string | undefined, unknown]> = Array.isArray(variables)
+ ? variables.map((value) => [undefined, value])
+ : Object.entries(variables)
+
+ for (const [key, value] of entries) {
+ if (!value || typeof value !== 'object') continue
+ const raw = value as Partial
+ const rawId = typeof raw.id === 'string' ? raw.id.trim() : ''
+ const id = rawId || key || generateId()
+
+ record[id] = {
+ id,
+ name: typeof raw.name === 'string' ? raw.name : id,
+ type: raw.type && VARIABLE_TYPES.has(raw.type) ? raw.type : 'string',
+ value: raw.value,
+ }
+ }
+
+ return { ...record }
+}
diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts
index feaf871af1b..eaa60d714bd 100644
--- a/apps/sim/stores/workflows/utils.ts
+++ b/apps/sim/stores/workflows/utils.ts
@@ -10,7 +10,7 @@ import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getBlock } from '@/blocks'
-import { normalizeName } from '@/executor/constants'
+import { escapeRegExp, normalizeName } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
@@ -218,7 +218,22 @@ function updateValueReferences(value: unknown, nameMap: Map): un
if (typeof value === 'string') {
let updatedValue = value
nameMap.forEach((newName, oldName) => {
- const regex = new RegExp(`<${oldName}\\.`, 'g')
+ /**
+ * A rename to itself is a no-op, so skip the scan entirely. This is the
+ * whole map on the import path (`regenerateWorkflowIds` seeds it with
+ * `name -> name`), which turns an O(names x values) rescan of every
+ * sub-block string into nothing.
+ */
+ if (oldName === newName) return
+
+ /**
+ * `oldName` is a block name, which reaches this function straight from
+ * imported workflow JSON — `normalizeWorkflowBlockName` only lowercases
+ * and strips whitespace/dots, so regex metacharacters survive. Without
+ * escaping, a name like `a*a*a*a*b` compiles to a catastrophically
+ * backtracking pattern that pins the event loop on a sub-kilobyte input.
+ */
+ const regex = new RegExp(`<${escapeRegExp(oldName)}\\.`, 'g')
updatedValue = updatedValue.replace(regex, `<${newName}.`)
})
return updatedValue
diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts
index 1712cdaa9f5..0751de73108 100644
--- a/scripts/check-api-validation-contracts.ts
+++ b/scripts/check-api-validation-contracts.ts
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
- totalRoutes: 977,
- zodRoutes: 977,
+ totalRoutes: 979,
+ zodRoutes: 979,
nonZodRoutes: 0,
} as const
From b148a52db17095ff70b2820b51243805d176cfdd Mon Sep 17 00:00:00 2001
From: Waleed
Date: Tue, 28 Jul 2026 07:38:12 -0700
Subject: [PATCH 06/10] fix(api): stop workspace import dropping every workflow
variable (#6002)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The workspace importer guarded its variable write on `Array.isArray`, but
`parseWorkflowVariables` — what the matching workspace export emits — returns
the record form. Every variable was silently dropped, so exporting a workspace
and importing it back lost all of them. It now uses the shared
`normalizeImportedVariables`, which accepts both shapes.
Also runs the workspace importer through `prepareWorkflowStateForPersistence`,
the pipeline the editor, the v1 import API and the single-workflow admin import
already share. Without it this route wrote raw parsed state, so a dangling edge
tripped the `workflow_edges` foreign key and failed the restore, and blocks
missing their backfilled columns could land unopenable.
Repoints `persistImportedWorkflow` at the same helper, removing the last
inline copy — all four import paths now normalize variables identically.
Adds the first tests for these helpers, including the export -> import round
trip that pins the record form the bug dropped.
---
.../v1/admin/workspaces/[id]/import/route.ts | 39 +++---
.../lib/workflows/operations/import-export.ts | 42 ++-----
.../sim/lib/workflows/variables/parse.test.ts | 119 ++++++++++++++++++
3 files changed, 154 insertions(+), 46 deletions(-)
create mode 100644 apps/sim/lib/workflows/variables/parse.test.ts
diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
index 7bbf9a5ea01..967e432ef39 100644
--- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
+++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
@@ -41,8 +41,10 @@ import {
extractWorkflowsFromZip,
parseWorkflowJson,
} from '@/lib/workflows/operations/import-export'
+import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
+import { normalizeImportedVariables } from '@/lib/workflows/variables/parse'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -52,7 +54,6 @@ import {
} from '@/app/api/v1/admin/responses'
import type {
ImportResult,
- WorkflowVariable,
WorkspaceImportRequest,
WorkspaceImportResponse,
} from '@/app/api/v1/admin/types'
@@ -270,7 +271,22 @@ async function importSingleWorkflow(
variables: {},
})
- const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
+ /**
+ * Same normalization the editor, the v1 import API and the single-workflow
+ * admin import run, via the one shared implementation. Without it this route
+ * wrote raw parsed state, so a dangling edge tripped the `workflow_edges`
+ * foreign key and failed the restore, and blocks missing their backfilled
+ * columns could land unopenable.
+ */
+ const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(workflowData)
+ if (warnings.length > 0) {
+ logger.warn(`Admin API: normalized "${dedupedName}" with warnings`, { warnings })
+ }
+
+ const saveResult = await saveWorkflowToNormalizedTables(workflowId, {
+ ...workflowData,
+ ...preparedState,
+ })
if (!saveResult.success) {
await db.delete(workflow).where(eq(workflow.id, workflowId))
@@ -282,18 +298,13 @@ async function importSingleWorkflow(
}
}
- if (workflowData.variables && Array.isArray(workflowData.variables)) {
- const variablesRecord: Record = {}
- workflowData.variables.forEach((v) => {
- const varId = v.id || generateId()
- variablesRecord[varId] = {
- id: varId,
- name: v.name,
- type: v.type || 'string',
- value: v.value,
- }
- })
-
+ /**
+ * Previously guarded on `Array.isArray`, which silently dropped every
+ * variable in the current record form — the exact shape this workspace's
+ * own export emits — so an export/import round trip lost all of them.
+ */
+ const variablesRecord = normalizeImportedVariables(workflowData.variables)
+ if (Object.keys(variablesRecord).length > 0) {
await db
.update(workflow)
.set({ variables: variablesRecord, updatedAt: new Date() })
diff --git a/apps/sim/lib/workflows/operations/import-export.ts b/apps/sim/lib/workflows/operations/import-export.ts
index 06967ab7969..13b658cca63 100644
--- a/apps/sim/lib/workflows/operations/import-export.ts
+++ b/apps/sim/lib/workflows/operations/import-export.ts
@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
@@ -18,6 +17,7 @@ import {
sanitizeForExport,
} from '@/lib/workflows/sanitization/json-sanitizer'
import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks'
+import { normalizeImportedVariables } from '@/lib/workflows/variables/parse'
import { regenerateWorkflowIds } from '@/stores/workflows/utils'
import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -724,37 +724,15 @@ export async function persistImportedWorkflow({
throw new Error(`Failed to save workflow state for ${newWorkflowId}`)
}
- if (workflowData.variables) {
- const variablesArray = Array.isArray(workflowData.variables)
- ? workflowData.variables
- : Object.values(workflowData.variables)
-
- if (variablesArray.length > 0) {
- type WorkflowVariablesBodyInput = NonNullable<
- Parameters>[1]['body']
- >
- const variablesRecord: WorkflowVariablesBodyInput['variables'] = {}
-
- for (const variable of variablesArray) {
- const id =
- typeof variable.id === 'string' && variable.id.trim() ? variable.id : generateId()
-
- variablesRecord[id] = {
- id,
- name: variable.name,
- type: variable.type,
- value: variable.value,
- }
- }
-
- try {
- await requestJson(workflowVariablesContract, {
- params: { id: newWorkflowId },
- body: { variables: variablesRecord },
- })
- } catch {
- throw new Error(`Failed to save variables for ${newWorkflowId}`)
- }
+ const variablesRecord = normalizeImportedVariables(workflowData.variables)
+ if (Object.keys(variablesRecord).length > 0) {
+ try {
+ await requestJson(workflowVariablesContract, {
+ params: { id: newWorkflowId },
+ body: { variables: variablesRecord },
+ })
+ } catch {
+ throw new Error(`Failed to save variables for ${newWorkflowId}`)
}
}
diff --git a/apps/sim/lib/workflows/variables/parse.test.ts b/apps/sim/lib/workflows/variables/parse.test.ts
new file mode 100644
index 00000000000..1484fa8604d
--- /dev/null
+++ b/apps/sim/lib/workflows/variables/parse.test.ts
@@ -0,0 +1,119 @@
+/**
+ * @vitest-environment node
+ *
+ * Pins the contract between the two halves of every workflow export/import
+ * round trip: `parseWorkflowVariables` produces what exports emit, and
+ * `normalizeImportedVariables` consumes what imports receive. A shape the first
+ * emits that the second drops is silent data loss on a restore, which is
+ * exactly the bug the workspace importer shipped with.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { normalizeImportedVariables, parseWorkflowVariables } from '@/lib/workflows/variables/parse'
+
+const VARIABLE = {
+ id: 'var-1',
+ name: 'apiHost',
+ type: 'string' as const,
+ value: 'https://example.com',
+}
+
+describe('parseWorkflowVariables', () => {
+ it('returns undefined for null so callers can omit the field', () => {
+ expect(parseWorkflowVariables(null)).toBeUndefined()
+ })
+
+ it('reads the current record form', () => {
+ expect(parseWorkflowVariables({ 'var-1': VARIABLE })).toEqual({ 'var-1': VARIABLE })
+ })
+
+ it('re-keys the legacy array form by variable id', () => {
+ expect(parseWorkflowVariables([VARIABLE] as never)).toEqual({ 'var-1': VARIABLE })
+ })
+
+ it('parses a JSON string column value', () => {
+ expect(parseWorkflowVariables(JSON.stringify({ 'var-1': VARIABLE }) as never)).toEqual({
+ 'var-1': VARIABLE,
+ })
+ })
+
+ it('skips legacy array rows without a usable id instead of emitting an "undefined" key', () => {
+ const result = parseWorkflowVariables([VARIABLE, { name: 'orphan' }, null] as never)
+
+ expect(Object.keys(result ?? {})).toEqual(['var-1'])
+ })
+})
+
+describe('normalizeImportedVariables', () => {
+ it('accepts the record form', () => {
+ expect(normalizeImportedVariables({ 'var-1': VARIABLE })).toEqual({ 'var-1': VARIABLE })
+ })
+
+ it('accepts the legacy array form', () => {
+ expect(normalizeImportedVariables([VARIABLE])).toEqual({ 'var-1': VARIABLE })
+ })
+
+ it('returns an empty record for null, undefined and non-objects', () => {
+ expect(normalizeImportedVariables(null)).toEqual({})
+ expect(normalizeImportedVariables(undefined)).toEqual({})
+ expect(normalizeImportedVariables('nope')).toEqual({})
+ })
+
+ it('falls back to the map key when an entry carries no id', () => {
+ expect(normalizeImportedVariables({ fromKey: { name: 'x', value: 1 } })).toMatchObject({
+ fromKey: { id: 'fromKey', name: 'x' },
+ })
+ })
+
+ it('coerces an unrecognized type to string rather than persisting it', () => {
+ const result = normalizeImportedVariables({ v: { ...VARIABLE, type: 'secret' } })
+
+ expect(result['var-1'].type).toBe('string')
+ })
+
+ it('keeps a __proto__-keyed variable as an ordinary own property', () => {
+ /**
+ * Built via `JSON.parse` rather than a literal: an object literal's
+ * `__proto__` sets the prototype, while `JSON.parse` creates a real own
+ * key — and `JSON.parse` is how an import payload actually arrives.
+ */
+ const payload = JSON.parse('{"__proto__": {"name": "sneaky", "value": 1}}')
+
+ const result = normalizeImportedVariables(payload)
+
+ expect(Object.keys(result)).toContain('__proto__')
+ expect(Object.getPrototypeOf(result)).toBe(Object.prototype)
+ })
+
+ it('trims a padded id so the key is referenceable', () => {
+ expect(Object.keys(normalizeImportedVariables([{ ...VARIABLE, id: ' var-1 ' }]))).toEqual([
+ 'var-1',
+ ])
+ })
+
+ it('skips non-object entries instead of writing junk variables', () => {
+ expect(normalizeImportedVariables(['nope', null, VARIABLE])).toEqual({ 'var-1': VARIABLE })
+ })
+})
+
+describe('export -> import variable round trip', () => {
+ /**
+ * The regression guard. Workspace export writes whatever
+ * `parseWorkflowVariables` returns — the record form — and the importer used
+ * to guard on `Array.isArray`, so every variable was silently dropped on
+ * restore.
+ */
+ it('survives the record form that exports actually emit', () => {
+ const exported = parseWorkflowVariables({ 'var-1': VARIABLE })
+
+ expect(exported).toBeDefined()
+ expect(Array.isArray(exported)).toBe(false)
+ expect(normalizeImportedVariables(exported)).toEqual({ 'var-1': VARIABLE })
+ })
+
+ it('survives a legacy array-form export', () => {
+ const exported = parseWorkflowVariables([VARIABLE] as never)
+
+ expect(normalizeImportedVariables(exported)).toEqual({ 'var-1': VARIABLE })
+ })
+})
From e5ae4451fc3cfb023b13968b376ca1e7d9e476f4 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Tue, 28 Jul 2026 09:01:14 -0700
Subject: [PATCH 07/10] improvement(tables): announce locks on open instead of
a header chip (#5979)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* improvement(tables): announce locks on open instead of a header chip
Drop the lock entry from the table header actions. A locked table now announces
itself once on open via an info toast carrying the Lock settings action for
admins; the breadcrumb dropdown remains the permanent entry point.
* fix(tables): wait for permissions before announcing table locks
The lock announcement fires once per table, so a canAdmin that is still false
because permissions have not resolved permanently drops the toast's Lock
settings action. Arm the one-shot on the permissions decision rather than on
table resolution.
* fix(tables): keep the lock announcement from being swept on navigation
The toast provider clears route-scoped toasts in a pathname effect that runs
after child effects, so an announcement fired on a warm-cache navigation was
added and removed in the same commit. Opt these toasts out of the sweep and
dismiss them on unmount so they still don't trail the user.
* fix(tables): drop the lock notice when its action stops being valid
A toast's action is captured at creation, so a viewer whose admin access is
revoked while the announcement is on screen kept a Lock settings button that
opened nothing. Dismiss the notice when the action is no longer available.
* fix(tables): only drop the lock notice when access is actually lost
The dismiss effect treated "has no access" the same as "just lost access", so
on a warm-cache mount — where the announcement and the dismiss both run in the
mount commit — a non-admin's notice was torn down the moment it was created.
Dismiss on the true-to-false transition only.
* fix(tables): clear the lock notice when switching tables
Switching tables reuses this component rather than remounting it, and the
notice is exempt from the provider's route sweep, so a locked table's
announcement stayed on screen over the next table — where its action would
have opened that table's lock settings instead. Key the cleanup on tableId.
* fix(emcn): sweep route-scoped toasts during render, not after child effects
The provider cleared route-scoped toasts from a pathname effect. Effects run
child-first, so it also swept toasts the newly rendered route had just raised:
any toast added from a child's mount effect — which is what happens whenever
that child's data is already cached — was appended and filtered out in the same
commit, before it painted.
Move the sweep into render, where it runs before children render, so only
toasts predating the navigation are cleared. Timers and heights were already
reconciled by effects keyed off `toasts`, so this stays a pure state update.
Drops the persistAcrossRoutes workaround the table lock notice needed to dodge
the bug; its tableId-keyed cleanup stays, covering embedded swaps that change
the prop without a route change.
* fix(tables): reset the announce latch when leaving a table
The tableId cleanup dismissed the notice on departure but left the latch
holding that table's id, so returning before another table announced — a quick
there-and-back, or a second table that never loaded — found the latch matching
and stayed silent. Leaving ends the visit, so clear the latch with it.
---
.../tables/[tableId]/lock-copy.ts | 8 +-
.../[workspaceId]/tables/[tableId]/table.tsx | 76 +++++++++++--------
packages/emcn/src/components/toast/toast.tsx | 49 ++++++------
3 files changed, 73 insertions(+), 60 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts
index f87a5bdbcd1..6f559998503 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts
@@ -1,6 +1,6 @@
/**
- * Single source of truth for lock vocabulary shared by the lock settings modal,
- * the blocked-action toast, and the table header chip. Kept out of
+ * Single source of truth for lock vocabulary shared by the lock settings modal
+ * and the lock toasts (the on-open announcement and blocked actions). Kept out of
* `lib/table/mutation-locks.ts` — that module is server-tainted (importing it
* from a client component pulls `next/headers` into the browser bundle).
*/
@@ -76,8 +76,8 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string
/**
* Why a locked-table notice was raised. `'status'` is the informational case
- * (a non-admin clicking the header lock chip); the rest are actions the user
- * just tried and couldn't do.
+ * (the announcement shown once when a locked table is opened); the rest are
+ * actions the user just tried and couldn't do.
*/
export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
index 1c0daa5fed7..a76f01abab5 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
import { Download, Lock, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
@@ -61,12 +61,7 @@ import {
import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants'
import { COLUMN_TYPE_ICONS } from './components/table-grid/headers'
import { useTable, useTableEventStream } from './hooks'
-import {
- type BlockedTableAction,
- describeBlockedAction,
- describeLocks,
- lockedNouns,
-} from './lock-copy'
+import { type BlockedTableAction, describeBlockedAction, lockedNouns } from './lock-copy'
import {
DEFAULT_TABLE_DETAIL_SORT_DIRECTION,
tableDetailParsers,
@@ -627,7 +622,10 @@ export function Table({
if (!tableData) return
if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current)
const { title, text } = describeBlockedAction(action, tableData.locks)
- blockedToastIdRef.current = toast.warning(title, {
+ // 'status' is the on-open announcement — nothing was refused, so it reads
+ // as information rather than a warning.
+ const notify = action === 'status' ? toast.info : toast.warning
+ blockedToastIdRef.current = notify(title, {
description: text,
...(canOpenLockSettings
? {
@@ -641,23 +639,48 @@ export function Table({
[tableData, canOpenLockSettings]
)
+ // Announce the lock state once per table on open. Unlike the re-rendering
+ // permission gates, this fires once and can't self-correct, so it waits for
+ // `canAdmin` to settle instead of treating loading as permitted.
+ const announcedLockTableIdRef = useRef(null)
+ useEffect(() => {
+ if (!tableData || userPermissions.isLoading) return
+ if (announcedLockTableIdRef.current === tableData.id) return
+ announcedLockTableIdRef.current = tableData.id
+ if (lockedNouns(tableData.locks).length === 0) return
+ showBlockedToast('status')
+ }, [tableData, userPermissions.isLoading, showBlockedToast])
+
+ // A notice must not outlive the table it describes — its action targets
+ // whichever table is current. Keyed on `tableId` so an embedded swap that
+ // changes the prop without a route change is covered too. Leaving ends the
+ // visit, so the latch resets and coming back announces again.
+ useEffect(
+ () => () => {
+ announcedLockTableIdRef.current = null
+ if (!blockedToastIdRef.current) return
+ toast.dismiss(blockedToastIdRef.current)
+ blockedToastIdRef.current = null
+ },
+ [tableId]
+ )
+
+ // A toast's action is captured when it is created, so a viewer who loses
+ // admin access mid-toast would keep a Lock settings button that opens
+ // nothing. Dismiss on that transition only — a viewer who never had access
+ // has a legitimate action-less notice that must survive.
+ const couldOpenLockSettingsRef = useRef(canOpenLockSettings)
+ useEffect(() => {
+ const lostAccess = couldOpenLockSettingsRef.current && !canOpenLockSettings
+ couldOpenLockSettingsRef.current = canOpenLockSettings
+ if (!lostAccess || !blockedToastIdRef.current) return
+ toast.dismiss(blockedToastIdRef.current)
+ blockedToastIdRef.current = null
+ }, [canOpenLockSettings])
+
const headerActions = useMemo(() => {
if (!tableData) return undefined
- // Header space is for state, not for settings: the chip appears only once
- // something is actually locked, and names the mode so it reads at a glance.
- // Reaching the panel on an unlocked table is the dropdown's job.
- const anyLocked = lockedNouns(tableData.locks).length > 0
return [
- ...(anyLocked
- ? [
- {
- label: describeLocks(tableData.locks).name,
- icon: Lock,
- onClick: () =>
- userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'),
- },
- ]
- : []),
{
label: 'Import CSV',
icon: Upload,
@@ -673,14 +696,7 @@ export function Table({
disabled: tableData.rowCount === 0,
},
]
- }, [
- tableData,
- userPermissions.canEdit,
- userPermissions.canAdmin,
- handleExportCsv,
- onRequestImportCsv,
- showBlockedToast,
- ])
+ }, [tableData, userPermissions.canEdit, handleExportCsv, onRequestImportCsv])
// Adding a column is a schema change. The trigger stays visible when the
// table is schema-locked and explains itself instead of disappearing.
diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx
index 3dee9af1541..f5778e2e687 100644
--- a/packages/emcn/src/components/toast/toast.tsx
+++ b/packages/emcn/src/components/toast/toast.tsx
@@ -396,6 +396,29 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
const [mounted, setMounted] = useState(false)
const timersRef = useRef(new Map>())
+ /**
+ * Clear the previous route's toasts when the route changes. Toasts flagged
+ * `persistAcrossRoutes` — global, ongoing-state notifications like the
+ * connection status — survive; page-scoped ones do not.
+ *
+ * Done during render rather than in an effect. Effects run child-first, so an
+ * effect here would also sweep toasts the newly rendered route just raised: a
+ * toast added from a child's mount effect — which is what happens whenever
+ * that child's data is already cached — would be appended and filtered out in
+ * the same commit, before it ever painted. Adjusting state during render runs
+ * before children render, so only toasts predating the navigation are cleared.
+ *
+ * Stays a pure state update: orphaned timers and measured heights are already
+ * reconciled by the effects below, which key off `toasts`.
+ */
+ const [sweptPathname, setSweptPathname] = useState(pathname)
+ if (pathname !== sweptPathname) {
+ setSweptPathname(pathname)
+ setToasts((prev) =>
+ prev.some((t) => !t.persistAcrossRoutes) ? prev.filter((t) => t.persistAcrossRoutes) : prev
+ )
+ }
+
useEffect(() => {
setMounted(true)
}, [])
@@ -459,27 +482,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
setHeights({})
}, [])
- /**
- * Clear only route-scoped toasts. Toasts flagged `persistAcrossRoutes` —
- * global, ongoing-state notifications like the connection status — survive,
- * everything else (page-scoped notifications) is cleared on navigation.
- */
- const dismissRouteScopedToasts = useCallback(() => {
- setToasts((prev) => {
- const kept = prev.filter((t) => t.persistAcrossRoutes)
- if (kept.length === prev.length) return prev
- for (const t of prev) {
- if (t.persistAcrossRoutes) continue
- const timer = timersRef.current.get(t.id)
- if (timer) {
- clearTimeout(timer)
- timersRef.current.delete(t.id)
- }
- }
- return kept
- })
- }, [])
-
const measureToast = useCallback((id: string, height: number) => {
setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }))
}, [])
@@ -536,11 +538,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
}
}, [])
- /** On navigation, clear route-scoped toasts so they don't trail the user; `persistAcrossRoutes` toasts survive. */
- useEffect(() => {
- dismissRouteScopedToasts()
- }, [pathname, dismissRouteScopedToasts])
-
/** Held in a ref (seeded once from the stable `addToast`) so the module-level `toast` binds to the live provider. */
const toastFn = useRef(createToastFn(addToast))
From bf376dd023d21cb06319d2d24b7de6530a77c16d Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Tue, 28 Jul 2026 10:23:12 -0700
Subject: [PATCH 08/10] fix(cleanup): compare live-paused statuses with IN, not
ANY over a row constructor (#6004)
---
apps/sim/background/cleanup-logs.ts | 8 ++--
.../payloads/large-value-metadata.ts | 10 ++---
.../payloads/live-paused-statuses-sql.test.ts | 40 +++++++++++++++++++
3 files changed, 49 insertions(+), 9 deletions(-)
create mode 100644 apps/sim/lib/execution/payloads/live-paused-statuses-sql.test.ts
diff --git a/apps/sim/background/cleanup-logs.ts b/apps/sim/background/cleanup-logs.ts
index 343e388ed2b..2ee922fb7e0 100644
--- a/apps/sim/background/cleanup-logs.ts
+++ b/apps/sim/background/cleanup-logs.ts
@@ -256,7 +256,7 @@ async function cleanupLegacyLargeExecutionValues(
SELECT 1
FROM ${pausedExecutions} AS ref_pe
WHERE ref_pe.execution_id = ref.execution_id
- AND ref_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
+ AND ref_pe.status IN ${LIVE_PAUSED_REFERENCE_STATUSES}
)
)
)
@@ -279,7 +279,7 @@ async function cleanupLegacyLargeExecutionValues(
SELECT 1
FROM ${pausedExecutions} AS parent_owner_pe
WHERE parent_owner_pe.execution_id = parent_value.owner_execution_id
- AND parent_owner_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
+ AND parent_owner_pe.status IN ${LIVE_PAUSED_REFERENCE_STATUSES}
)
OR EXISTS (
SELECT 1
@@ -300,7 +300,7 @@ async function cleanupLegacyLargeExecutionValues(
SELECT 1
FROM ${pausedExecutions} AS parent_ref_pe
WHERE parent_ref_pe.execution_id = parent_ref.execution_id
- AND parent_ref_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
+ AND parent_ref_pe.status IN ${LIVE_PAUSED_REFERENCE_STATUSES}
)
)
)
@@ -316,7 +316,7 @@ async function cleanupLegacyLargeExecutionValues(
SELECT 1
FROM ${pausedExecutions} AS pe
WHERE pe.execution_id = split_part(${workspaceFiles.key}, '/', 4)
- AND pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
+ AND pe.status IN ${LIVE_PAUSED_REFERENCE_STATUSES}
)`
)
)
diff --git a/apps/sim/lib/execution/payloads/large-value-metadata.ts b/apps/sim/lib/execution/payloads/large-value-metadata.ts
index 41d56fbf2e2..c98f4756c5e 100644
--- a/apps/sim/lib/execution/payloads/large-value-metadata.ts
+++ b/apps/sim/lib/execution/payloads/large-value-metadata.ts
@@ -412,7 +412,7 @@ async function pruneStaleReferences(
SELECT 1
FROM ${pausedExecutions} AS pe
WHERE pe.execution_id = ref.execution_id
- AND pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
+ AND pe.status IN ${LIVE_PAUSED_REFERENCE_STATUSES}
)
)
OR ref.source NOT IN ('execution_log', 'paused_snapshot')
@@ -568,7 +568,7 @@ export function unreferencedLargeValuePredicate() {
SELECT 1
FROM ${pausedExecutions} AS pe
WHERE pe.execution_id = elvr.execution_id
- AND pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
+ AND pe.status IN ${LIVE_PAUSED_REFERENCE_STATUSES}
)
)
)
@@ -582,7 +582,7 @@ export function unreferencedLargeValuePredicate() {
SELECT 1
FROM ${pausedExecutions} AS owner_pe
WHERE owner_pe.execution_id = ${executionLargeValues.ownerExecutionId}
- AND owner_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
+ AND owner_pe.status IN ${LIVE_PAUSED_REFERENCE_STATUSES}
)
AND NOT EXISTS (
SELECT 1
@@ -602,7 +602,7 @@ export function unreferencedLargeValuePredicate() {
SELECT 1
FROM ${pausedExecutions} AS parent_owner_pe
WHERE parent_owner_pe.execution_id = parent_value.owner_execution_id
- AND parent_owner_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
+ AND parent_owner_pe.status IN ${LIVE_PAUSED_REFERENCE_STATUSES}
)
OR EXISTS (
SELECT 1
@@ -623,7 +623,7 @@ export function unreferencedLargeValuePredicate() {
SELECT 1
FROM ${pausedExecutions} AS parent_ref_pe
WHERE parent_ref_pe.execution_id = parent_ref.execution_id
- AND parent_ref_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
+ AND parent_ref_pe.status IN ${LIVE_PAUSED_REFERENCE_STATUSES}
)
)
)
diff --git a/apps/sim/lib/execution/payloads/live-paused-statuses-sql.test.ts b/apps/sim/lib/execution/payloads/live-paused-statuses-sql.test.ts
new file mode 100644
index 00000000000..812b1d64944
--- /dev/null
+++ b/apps/sim/lib/execution/payloads/live-paused-statuses-sql.test.ts
@@ -0,0 +1,40 @@
+/**
+ * @vitest-environment node
+ */
+
+// Renders the real predicate against the real drizzle dialect and schema: the
+// bug this guards against is a SQL *rendering* bug, so the global drizzle-orm
+// and schema mocks would hide it entirely.
+import { describe, expect, it, vi } from 'vitest'
+
+vi.unmock('drizzle-orm')
+vi.unmock('@sim/db')
+vi.unmock('@sim/db/schema')
+
+process.env.DATABASE_URL ??= 'postgresql://user:pass@localhost:5432/test'
+
+const { PgDialect } = await import('drizzle-orm/pg-core')
+const { LIVE_PAUSED_REFERENCE_STATUSES, unreferencedLargeValuePredicate } = await import(
+ '@/lib/execution/payloads/large-value-metadata'
+)
+
+describe('unreferencedLargeValuePredicate SQL', () => {
+ const { sql: text, params } = new PgDialect().sqlToQuery(unreferencedLargeValuePredicate())
+
+ it('compares paused status against an IN list', () => {
+ expect(text).toMatch(/\.status IN \(\$\d+, \$\d+, \$\d+\)/)
+ expect(params).toEqual(expect.arrayContaining([...LIVE_PAUSED_REFERENCE_STATUSES]))
+ })
+
+ it('never emits ANY() over drizzle-expanded statuses', () => {
+ // Drizzle renders a JS array as `($1, $2, $3)` — correct for IN, but
+ // `ANY((...)::text[])` makes Postgres reject the whole statement with
+ // "cannot cast type record to text[]", which killed cleanup-logs for two months.
+ expect(text).not.toMatch(/ANY\(\(\$\d+/)
+ })
+
+ it('never emits a dangling comparison operator before IN', () => {
+ // `status = IN (...)` is a syntax error Postgres only reports at execution time.
+ expect(text).not.toMatch(/=\s*IN\s*\(/)
+ })
+})
From 3df01e7de0927717f21d913b996f1b2a3c95011d Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 28 Jul 2026 10:45:41 -0700
Subject: [PATCH 09/10] chore(trigger): upsize task size (#6007)
* chore(trigger): upsize instances
* chore(trigger): upsize tasks
---
.../components/activity-log/activity-log.tsx | 86 +++++----
apps/sim/background/fork-content-copy.ts | 6 +
.../background/knowledge-connector-sync.ts | 2 +-
apps/sim/background/schedule-execution.ts | 2 +-
apps/sim/background/workflow-execution.ts | 2 +-
.../fork-activity-panel.test.tsx | 171 ++++++++++++++++++
.../fork-activity-panel.tsx | 49 ++++-
7 files changed, 274 insertions(+), 44 deletions(-)
create mode 100644 apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.test.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx
index 12a3adccf4d..32f9d5dd075 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx
@@ -32,6 +32,8 @@ const EVENT_COLUMN_WIDTH_CLASS = {
type EventColumnWidth = keyof typeof EVENT_COLUMN_WIDTH_CLASS
+const ROW_CLASS = 'flex w-full items-center gap-3 px-3 py-2 text-left'
+
function ActivityLogRow({
entry,
eventColumn,
@@ -42,6 +44,39 @@ function ActivityLogRow({
const [expanded, setExpanded] = useState(false)
const expandable = entry.details != null
+ const cells = (
+ <>
+
+ {entry.timestamp}
+
+
+ {entry.event}
+
+
+ {typeof entry.description === 'string' ? (
+
+ ) : (
+ entry.description
+ )}
+
+
+ {typeof entry.actor === 'string' ? (
+
+ ) : (
+ {entry.actor}
+ )}
+ {expandable && (
+
+ )}
+
+ >
+ )
+
return (
-
+ {expandable ? (
+
+ ) : (
+ // A row with nothing to expand is inert content, not a disabled control:
+ // browsers suppress pointer events over a disabled button AND its
+ // descendants, which would swallow the hover tooltips inside the cells.
+
{cells}
+ )}
{expandable && expanded && (
diff --git a/apps/sim/background/fork-content-copy.ts b/apps/sim/background/fork-content-copy.ts
index 7391b78c2c4..8b721cecc62 100644
--- a/apps/sim/background/fork-content-copy.ts
+++ b/apps/sim/background/fork-content-copy.ts
@@ -11,9 +11,15 @@ import {
* non-transactional best-effort (per-row inserts with fresh ids), so a blind
* re-run would duplicate rows; a partial failure simply leaves the fork's content
* incomplete (the workflows themselves committed synchronously).
+ *
+ * Runs on `large-2x` (8 vCPU / 16 GB), matching `knowledge-connector-sync`: the
+ * copy materializes table rows, KB chunks and their embedding vectors, and file
+ * blobs in memory, and `maxAttempts: 1` means an OOM is unrecoverable — a
+ * half-copied fork with no retry.
*/
export const forkContentCopyTask = task({
id: 'fork-content-copy',
+ machine: 'large-2x',
retry: { maxAttempts: 1 },
queue: {
name: 'fork-content-copy',
diff --git a/apps/sim/background/knowledge-connector-sync.ts b/apps/sim/background/knowledge-connector-sync.ts
index f92c440a146..ee17426f2bc 100644
--- a/apps/sim/background/knowledge-connector-sync.ts
+++ b/apps/sim/background/knowledge-connector-sync.ts
@@ -40,7 +40,7 @@ export async function executeConnectorSyncJob(payload: unknown) {
export const knowledgeConnectorSync = task({
id: 'knowledge-connector-sync',
maxDuration: 1800,
- machine: 'large-1x',
+ machine: 'large-2x',
retry: {
maxAttempts: 3,
factor: 2,
diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts
index da02f0ebfaa..d7f0aae3643 100644
--- a/apps/sim/background/schedule-execution.ts
+++ b/apps/sim/background/schedule-execution.ts
@@ -1457,7 +1457,7 @@ export async function executeJobInline(payload: JobExecutionPayload) {
export const scheduleExecutionTaskOptions = {
id: 'schedule-execution',
- machine: 'medium-1x' as const,
+ machine: 'medium-2x' as const,
retry: {
maxAttempts: 1,
},
diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts
index 1be362811d2..cce7d55dd3e 100644
--- a/apps/sim/background/workflow-execution.ts
+++ b/apps/sim/background/workflow-execution.ts
@@ -230,7 +230,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
export const workflowExecutionTask = task({
id: 'workflow-execution',
- machine: 'medium-1x',
+ machine: 'medium-2x',
queue: {
concurrencyLimit: WORKFLOW_EXECUTION_CONCURRENCY_LIMIT,
},
diff --git a/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.test.tsx b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.test.tsx
new file mode 100644
index 00000000000..af633e4e6cd
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.test.tsx
@@ -0,0 +1,171 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'
+
+const { mockUseWorkspaceBackgroundWork } = vi.hoisted(() => ({
+ mockUseWorkspaceBackgroundWork: vi.fn(),
+}))
+
+vi.mock('@/ee/workspace-forking/hooks/background-work', () => ({
+ useWorkspaceBackgroundWork: mockUseWorkspaceBackgroundWork,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({
+ SettingsEmptyState: ({ children }: { children: React.ReactNode }) =>
{children}
,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/components', () => ({
+ FloatingOverflowText: ({ label, className }: { label: string; className?: string }) => (
+ {label}
+ ),
+}))
+
+import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel'
+
+const WORKSPACE_ID = 'ws-1'
+const PARTNER_ID = 'ws-parent'
+const WORKSPACE_NAMES = new Map([[PARTNER_ID, 'another workspace']])
+
+function makeJob(overrides: Partial = {}): BackgroundWorkItem {
+ return {
+ id: 'job-1',
+ workspaceId: PARTNER_ID,
+ workflowId: null,
+ kind: 'fork_content_copy',
+ status: 'completed',
+ message: null,
+ error: null,
+ metadata: { childWorkspaceId: WORKSPACE_ID, actorName: 'Brandon Tarr' },
+ startedAt: '2026-07-28T15:58:00.000Z',
+ completedAt: null,
+ ...overrides,
+ } as BackgroundWorkItem
+}
+
+let container: HTMLDivElement
+let root: Root
+
+function renderJobs(jobs: BackgroundWorkItem[]) {
+ mockUseWorkspaceBackgroundWork.mockReturnValue({
+ data: { pages: [{ items: jobs, nextCursor: null }] },
+ isPending: false,
+ isError: false,
+ hasNextPage: false,
+ fetchNextPage: vi.fn(),
+ isFetchingNextPage: false,
+ })
+ act(() => {
+ root.render()
+ })
+}
+
+/** The Event-column badge for the single rendered row (`Badge`'s base class). */
+function badgeElement(): HTMLElement {
+ const badge = container.querySelector('.inline-flex')
+ if (!badge) throw new Error('badge not found')
+ return badge
+}
+
+/** Hover the badge the way React sees it — `onPointerEnter` is delegated from `pointerover`. */
+function hover(element: HTMLElement) {
+ act(() => {
+ element.dispatchEvent(
+ new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
+ )
+ })
+}
+
+function tooltipText(): string | null {
+ return document.body.querySelector('[role="tooltip"]')?.textContent ?? null
+}
+
+describe('ForkActivityPanel event badge tooltip', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('shows the failure reason when hovering a failed (red) badge', () => {
+ renderJobs([makeJob({ status: 'failed', error: 'Storage quota exceeded while copying files' })])
+
+ expect(tooltipText()).toBeNull()
+ hover(badgeElement())
+ expect(tooltipText()).toBe('Storage quota exceeded while copying files')
+ })
+
+ it('falls back to a generic label when a failed row carries no error text', () => {
+ renderJobs([makeJob({ status: 'failed', error: null })])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBe('Failed')
+ })
+
+ it('truncates a very long failure reason', () => {
+ renderJobs([makeJob({ status: 'failed', error: 'x'.repeat(500) })])
+
+ hover(badgeElement())
+ const text = tooltipText() ?? ''
+ expect(text.endsWith('...')).toBe(true)
+ expect(text.length).toBeLessThan(260)
+ })
+
+ it('says "In progress" when hovering a processing (grey) badge', () => {
+ renderJobs([makeJob({ status: 'processing' })])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBe('In progress')
+ })
+
+ it('says "Queued" when hovering a pending (grey) badge', () => {
+ renderJobs([makeJob({ status: 'pending' })])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBe('Queued')
+ })
+
+ it('shows nothing when hovering a completed (blue) badge', () => {
+ renderJobs([makeJob({ status: 'completed' })])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBeNull()
+ })
+
+ it('surfaces the partial-copy summary on a completed_with_warnings (amber) badge', () => {
+ renderJobs([
+ makeJob({
+ status: 'completed_with_warnings',
+ message: 'Copied 12 items; 3 could not be copied',
+ }),
+ ])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBe('Copied 12 items; 3 could not be copied')
+ })
+
+ it('keeps a still-running row hoverable by not rendering it as a disabled button', () => {
+ renderJobs([makeJob({ status: 'processing' })])
+
+ // A processing row has no report yet, so it is not expandable. It must still
+ // be a plain row — a disabled button would swallow the badge's hover events.
+ expect(container.querySelector('button[disabled]')).toBeNull()
+ })
+
+ it('still renders an expandable row as a button', () => {
+ renderJobs([makeJob({ status: 'failed', error: 'boom' })])
+
+ const row = container.querySelector('button')
+ expect(row).not.toBeNull()
+ expect(row?.getAttribute('aria-expanded')).toBe('false')
+ })
+})
diff --git a/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx
index 81faf69ef2c..d0d5d7f4ceb 100644
--- a/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx
+++ b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx
@@ -1,9 +1,10 @@
'use client'
import { useCallback, useMemo } from 'react'
-import { Badge, Button } from '@sim/emcn'
+import { Badge, Button, Tooltip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDateTime } from '@sim/utils/formatting'
+import { truncate } from '@sim/utils/string'
import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'
import {
ActivityLog,
@@ -14,6 +15,12 @@ import { useWorkspaceBackgroundWork } from '@/ee/workspace-forking/hooks/backgro
const logger = createLogger('ForkActivityPanel')
+/**
+ * Errors can carry a full driver message; the badge tooltip is a glance-level
+ * summary, so cap it. The untruncated text stays in the expanded detail box.
+ */
+const TOOLTIP_MAX_LENGTH = 240
+
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
/** Join "N verb" segments (verbs like "updated" aren't pluralized), dropping zero counts. */
@@ -122,6 +129,27 @@ function jobBadgeVariant(job: BackgroundWorkItem) {
}
}
+/**
+ * Hover text for the Event badge, explaining what its color means. Successful rows
+ * (the per-operation colors) return null — the color already says "done", and the
+ * breakdown is one click away in the expanded row — so only the states a reader
+ * can't act on from color alone get a tooltip.
+ */
+function jobStatusTooltip(job: BackgroundWorkItem): string | null {
+ switch (job.status) {
+ case 'pending':
+ return 'Queued'
+ case 'processing':
+ return 'In progress'
+ case 'failed':
+ return truncate(job.error ?? 'Failed', TOOLTIP_MAX_LENGTH)
+ case 'completed_with_warnings':
+ return truncate(job.message ?? 'Completed with warnings', TOOLTIP_MAX_LENGTH)
+ default:
+ return null
+ }
+}
+
/** Build a job's report (named groups + plain notes) from its metadata. */
function jobReport(job: BackgroundWorkItem): JobReport {
const m = job.metadata
@@ -239,13 +267,24 @@ function jobDetails(job: BackgroundWorkItem, report: JobReport) {
function toActivityEntry(job: BackgroundWorkItem, view: ActivityView): ActivityLogEntry {
const report = jobReport(job)
const hasDetails = report.groups.length > 0 || report.notes.length > 0 || Boolean(job.error)
+ const tooltip = jobStatusTooltip(job)
+ const badge = (
+
+ {jobEventLabel(job)}
+
+ )
return {
id: job.id,
timestamp: formatDateTime(new Date(job.startedAt)),
- event: (
-
- {jobEventLabel(job)}
-
+ event: tooltip ? (
+
+ {badge}
+
+ {tooltip}
+
+
+ ) : (
+ badge
),
description: jobTitle(job, view),
actor: job.metadata?.actorName || 'System',
From ec3156f1bcbeb202b7fc96f65c8daab98738a618 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Tue, 28 Jul 2026 10:59:14 -0700
Subject: [PATCH 10/10] fix(files): count the document body against the export
limit (#6006)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(files): count the document body against the export limit
The 250 MB export cap measured only the embedded assets' declared sizes. The
markdown body was downloaded with no limit and never counted, so a large
document with modest attachments cleared the check and still produced a zip well
over the stated limit, materialized whole in memory.
The body is the largest single entry in most bundles, so excluding it left the
limit unenforced against the item most able to exceed it. It is now capped on
read and counted alongside its assets, and the message names both.
Follow-up to #5995, which introduced the asset caps without extending them to
the body.
* test(v1): cover the file download's rendered-bytes behavior
The route had no tests, and #5995 changed what it serves: rendered bytes, the
resolved content type rather than the record's source MIME, Content-Length from
the rendered length, and a retryable 409 while an artifact compiles.
One test pins the filename/content-type relationship. A review flagged the
download as naming a rendered file with a source extension, but the renderer
picks its output format from the file name — getE2BDocFormat and
COMPILABLE_FORMATS both key on it — so a .docx renders to a docx and the two
cannot disagree. The test makes that argument executable rather than a comment.
* fix(files): report an oversized document body as a size rejection
Capping the body read meant an oversized document threw PayloadSizeLimitError,
which nothing caught, so the caller got a generic 500 — hiding the very limit
message the cap was added to produce. It now returns the same 400 as the bundle
check, naming the export limit.
* chore(files): drop the unreachable export asset-count cap
extractEmbeddedFileRefs stops collecting at MAX_EMBEDDED_IMAGES, so the list this
route receives is already bounded before it arrives and the count check could
never fire. Its test only passed because it mocked the extractor, so it asserted
a branch production cannot reach.
The byte ceilings are the real bound and stay. A comment records where the count
is actually enforced, so the next reader does not add a second one.
---
.../app/api/files/export/[id]/route.test.ts | 47 ++++--
apps/sim/app/api/files/export/[id]/route.ts | 57 ++++----
.../app/api/v1/files/[fileId]/route.test.ts | 135 ++++++++++++++++++
3 files changed, 202 insertions(+), 37 deletions(-)
create mode 100644 apps/sim/app/api/v1/files/[fileId]/route.test.ts
diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts
index 1056fd61e05..069e1fc30a9 100644
--- a/apps/sim/app/api/files/export/[id]/route.test.ts
+++ b/apps/sim/app/api/files/export/[id]/route.test.ts
@@ -4,6 +4,7 @@
import { createMockRequest } from '@sim/testing'
import JSZip from 'jszip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
const {
mockCheckAuth,
@@ -79,19 +80,6 @@ describe('markdown export bundling', () => {
mockExtractEmbeddedImageIds.mockReturnValue([])
})
- it('rejects a document embedding more assets than an export may bundle', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue(
- Array.from({ length: 501 }, (_, index) => `img-${index}`)
- )
-
- const response = await GET(request(), context)
-
- expect(response.status).toBe(400)
- expect((await response.json()).error).toContain('501')
- // Rejected on the embed count alone: nothing was resolved or downloaded.
- expect(mockGetFileMetadataById).toHaveBeenCalledTimes(1)
- })
-
it('rejects on declared asset bytes before downloading any of them', async () => {
mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c'])
mockGetFileMetadataById.mockImplementation(async (id: string) =>
@@ -116,6 +104,39 @@ describe('markdown export bundling', () => {
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
})
+ it('counts the document body against the export limit, not just its assets', async () => {
+ // Assets alone sit under the cap; the body is what carries the bundle over it.
+ mockExtractEmbeddedImageIds.mockReturnValue(['a'])
+ mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB))
+
+ const response = await GET(request(), context)
+
+ expect(response.status).toBe(400)
+ expect((await response.json()).error).toContain('document and its embedded files')
+ })
+
+ it('caps the document body read rather than loading it unbounded', async () => {
+ mockExtractEmbeddedImageIds.mockReturnValue([])
+
+ await GET(request(), context)
+
+ const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md'))
+ expect(bodyCall?.[0].maxBytes).toBe(250 * MB)
+ })
+
+ it('reports an oversized body as a size rejection, not a server error', async () => {
+ mockExtractEmbeddedImageIds.mockReturnValue([])
+ mockDownloadFile.mockRejectedValue(
+ new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 })
+ )
+
+ const response = await GET(request(), context)
+
+ // The cap exists to produce a clear limit message; a 500 would hide it.
+ expect(response.status).toBe(400)
+ expect((await response.json()).error).toContain('export limit')
+ })
+
it('caps each asset download rather than trusting its declared size', async () => {
mockExtractEmbeddedImageIds.mockReturnValue(['a'])
diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts
index 6b217f9c6c1..0e578ada87b 100644
--- a/apps/sim/app/api/files/export/[id]/route.ts
+++ b/apps/sim/app/api/files/export/[id]/route.ts
@@ -10,6 +10,7 @@ import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
+import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import type { StorageContext } from '@/lib/uploads/config'
@@ -23,16 +24,14 @@ import { encodeFilenameForHeader } from '@/app/api/files/utils'
const logger = createLogger('FilesExportAPI')
/**
- * Bundling caps. The embed list comes from scanning the document body, so its length
- * and the bytes behind it are whatever the author put there — without these the export
- * would materialize an unbounded number of unbounded assets in one request.
+ * Byte ceilings for a bundled export. The bytes behind an embed list are whatever the
+ * author put there, so without these the export would materialize unbounded assets in
+ * one request. They match the bulk-download route, so the two export surfaces reject at
+ * the same size.
*
- * The byte ceilings are the real bound and match the bulk-download route, so the two
- * export surfaces reject at the same size. The count is only a guard on the metadata
- * lookups that precede the byte check, so it sits far above any hand-authored document
- * rather than at a number a screenshot-heavy doc could plausibly reach.
+ * There is deliberately no count cap here: `extractEmbeddedFileRefs` already stops at
+ * `MAX_EMBEDDED_IMAGES`, so the list this route receives is bounded before it arrives.
*/
-const MAX_EXPORT_ASSETS = 500
const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024
const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024
@@ -128,10 +127,26 @@ export const GET = withRouteHandler(
return NextResponse.redirect(new URL(servePath, request.url), { status: 302 })
}
- const mdBuffer = await downloadFile({
- key: record.key,
- context: record.context as StorageContext,
- })
+ // Capped like everything else in the bundle: the document body is usually the
+ // largest single entry, so leaving it unbounded left the export limit unenforced
+ // against the one item most able to exceed it. A body that alone exceeds the limit
+ // is a size rejection, so it reports as one rather than as a server error.
+ let mdBuffer: Buffer
+ try {
+ mdBuffer = await downloadFile({
+ key: record.key,
+ context: record.context as StorageContext,
+ maxBytes: MAX_EXPORT_TOTAL_BYTES,
+ })
+ } catch (error) {
+ if (!isPayloadSizeLimitError(error)) throw error
+ return NextResponse.json(
+ {
+ error: `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`,
+ },
+ { status: 400 }
+ )
+ }
let mdContent = mdBuffer.toString('utf-8')
const imageIds = extractEmbeddedImageIds(mdContent)
@@ -152,15 +167,6 @@ export const GET = withRouteHandler(
})
}
- if (imageIds.length > MAX_EXPORT_ASSETS) {
- return NextResponse.json(
- {
- error: `This document embeds ${imageIds.length} files, more than the ${MAX_EXPORT_ASSETS} an export can bundle.`,
- },
- { status: 400 }
- )
- }
-
// Metadata first: declared sizes bound the download before a byte is read, and the
// authorization check costs nothing to run here.
const assetTargets = (
@@ -180,11 +186,14 @@ export const GET = withRouteHandler(
})
).filter((target): target is NonNullable => target !== null)
- const declaredAssetBytes = assetTargets.reduce((sum, target) => sum + target.record.size, 0)
- if (declaredAssetBytes > MAX_EXPORT_TOTAL_BYTES) {
+ // The body counts against the same budget as its assets — the zip holds both, so a
+ // limit that measured only the attachments would not describe the archive produced.
+ const bundleBytes =
+ mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.record.size, 0)
+ if (bundleBytes > MAX_EXPORT_TOTAL_BYTES) {
return NextResponse.json(
{
- error: `Embedded files total ${formatFileSize(declaredAssetBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`,
+ error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`,
},
{ status: 400 }
)
diff --git a/apps/sim/app/api/v1/files/[fileId]/route.test.ts b/apps/sim/app/api/v1/files/[fileId]/route.test.ts
new file mode 100644
index 00000000000..9a85c408bb1
--- /dev/null
+++ b/apps/sim/app/api/v1/files/[fileId]/route.test.ts
@@ -0,0 +1,135 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockValidateWorkspaceAccess,
+ mockGetWorkspaceFile,
+ mockFetchServableWorkspaceFileBuffer,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockValidateWorkspaceAccess: vi.fn(),
+ mockGetWorkspaceFile: vi.fn(),
+ mockFetchServableWorkspaceFileBuffer: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ createRateLimitResponse: () => new Response('rate limited', { status: 429 }),
+ validateWorkspaceAccess: mockValidateWorkspaceAccess,
+}))
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ getWorkspaceFile: mockGetWorkspaceFile,
+ fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer,
+}))
+vi.mock('@/lib/workspace-files/orchestration', () => ({
+ performDeleteWorkspaceFileItems: vi.fn(),
+}))
+vi.mock('@sim/audit', () => ({
+ recordAudit: vi.fn(),
+ AuditAction: { FILE_DOWNLOADED: 'file.downloaded', FILE_DELETED: 'file.deleted' },
+ AuditResourceType: { FILE: 'file' },
+}))
+vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
+
+import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile'
+import { GET } from '@/app/api/v1/files/[fileId]/route'
+
+const WORKSPACE_ID = 'ws-1'
+const FILE_ID = 'file-1'
+const context = { params: Promise.resolve({ fileId: FILE_ID }) }
+
+const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+
+function request() {
+ return createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost:3000/api/v1/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`
+ )
+}
+
+/** A generated document: the name carries the target extension, the type the source. */
+function generatedDocument(name = 'report.docx') {
+ return {
+ id: FILE_ID,
+ workspaceId: WORKSPACE_ID,
+ name,
+ key: `workspace/${WORKSPACE_ID}/${FILE_ID}`,
+ path: `/serve/${FILE_ID}`,
+ size: 6_242,
+ type: 'text/x-docxjs',
+ uploadedBy: 'user-1',
+ uploadedAt: new Date('2026-01-01'),
+ updatedAt: new Date('2026-01-01'),
+ }
+}
+
+describe('v1 file download', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
+ mockValidateWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceFile.mockResolvedValue(generatedDocument())
+ mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
+ buffer: Buffer.from('PKrendered'),
+ contentType: DOCX_MIME,
+ })
+ })
+
+ it('serves the rendered bytes and the rendered content type', async () => {
+ const response = await GET(request(), context)
+
+ expect(response.status).toBe(200)
+ // Not the record's `text/x-docxjs`, which describes the stored source.
+ expect(response.headers.get('Content-Type')).toBe(DOCX_MIME)
+ expect(Buffer.from(await response.arrayBuffer()).toString()).toContain('rendered')
+ })
+
+ it('names the download with an extension matching the served content type', async () => {
+ // The renderer picks its output format from the file name, so the two cannot
+ // disagree: a `.docx` renders to a docx. This pins that invariant.
+ const response = await GET(request(), context)
+
+ const disposition = response.headers.get('Content-Disposition') ?? ''
+ expect(disposition).toContain('report.docx')
+ expect(response.headers.get('Content-Type')).toBe(DOCX_MIME)
+ })
+
+ it('reports Content-Length from the rendered bytes, not the declared source size', async () => {
+ const rendered = Buffer.alloc(50_000)
+ mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
+ buffer: rendered,
+ contentType: DOCX_MIME,
+ })
+
+ const response = await GET(request(), context)
+
+ expect(response.headers.get('Content-Length')).toBe(String(rendered.length))
+ })
+
+ it('returns a retryable 409 while the artifact is still compiling', async () => {
+ mockFetchServableWorkspaceFileBuffer.mockRejectedValue(
+ new DocCompileUserError('Document is still being generated')
+ )
+
+ const response = await GET(request(), context)
+
+ // A 500 would give the caller no reason to try again.
+ expect(response.status).toBe(409)
+ expect((await response.json()).error).toContain('still being generated')
+ })
+
+ it('404s a file that does not exist', async () => {
+ mockGetWorkspaceFile.mockResolvedValue(null)
+
+ const response = await GET(request(), context)
+
+ expect(response.status).toBe(404)
+ expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled()
+ })
+})