Skip to content

Commit a6d6213

Browse files
committed
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.
1 parent fd21368 commit a6d6213

3 files changed

Lines changed: 75 additions & 12 deletions

File tree

apps/sim/app/api/v1/admin/workflows/import/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin'
2424
import { parseRequest } from '@/lib/api/server'
2525
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2626
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
27+
import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
2728
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
2829
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
2930
import { normalizeImportedVariables } from '@/lib/workflows/variables/parse'
@@ -106,7 +107,21 @@ export const POST = withRouteHandler(
106107
variables: {},
107108
})
108109

109-
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
110+
/**
111+
* Same normalization the editor and the v1 import API run, via the one
112+
* shared implementation — without it this route wrote raw parsed state,
113+
* so a dangling edge tripped the `workflow_edges` foreign key and a block
114+
* missing its backfilled columns could land unopenable.
115+
*/
116+
const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(workflowData)
117+
if (warnings.length > 0) {
118+
logger.warn('Admin API: normalized imported workflow with warnings', { warnings })
119+
}
120+
121+
const saveResult = await saveWorkflowToNormalizedTables(workflowId, {
122+
...workflowData,
123+
...preparedState,
124+
})
110125

111126
if (!saveResult.success) {
112127
await db.delete(workflow).where(eq(workflow.id, workflowId))

apps/sim/app/api/v1/workflows/import/route.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,37 @@ describe('POST /api/v1/workflows/import', () => {
406406
expect(whereSpy).toHaveBeenCalled()
407407
})
408408

409+
it('caps a payload-derived name at the declared bound, ellipsis included', async () => {
410+
await POST(
411+
makeRequest(
412+
validBody({
413+
workflow: { ...EXPORT_ENVELOPE, workflow: { name: 'N'.repeat(500) } },
414+
})
415+
)
416+
)
417+
418+
const { name } = mockPerformCreateWorkflow.mock.calls[0][0]
419+
expect(name.length).toBeLessThanOrEqual(200)
420+
expect(name.endsWith('...')).toBe(true)
421+
})
422+
423+
it('prefers state.metadata.name, matching the in-app importer', async () => {
424+
await POST(
425+
makeRequest(
426+
validBody({
427+
workflow: {
428+
workflow: { name: 'FromWorkflow' },
429+
state: { ...EXPORT_ENVELOPE.state, metadata: { name: 'FromStateMetadata' } },
430+
},
431+
})
432+
)
433+
)
434+
435+
expect(mockPerformCreateWorkflow).toHaveBeenCalledWith(
436+
expect.objectContaining({ name: 'FromStateMetadata' })
437+
)
438+
})
439+
409440
it('unwraps an export response envelope so its metadata still resolves', async () => {
410441
await POST(makeRequest(validBody({ workflow: { data: EXPORT_ENVELOPE, limits: {} } })))
411442

apps/sim/app/api/v1/workflows/import/route.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ const MAX_IMPORT_BODY_BYTES = 10 * 1024 * 1024
4949

5050
const DEFAULT_IMPORTED_WORKFLOW_NAME = 'Imported Workflow'
5151

52+
const TRUNCATION_SUFFIX = '...'
53+
54+
/**
55+
* Caps a payload-derived string at `maxLength` *including* the ellipsis.
56+
* `truncate` appends its suffix after slicing, so passing the limit straight
57+
* through would yield `maxLength + 3` characters and overshoot the very bound
58+
* this is enforcing.
59+
*/
60+
function capLength(value: string, maxLength: number): string {
61+
return truncate(value, maxLength - TRUNCATION_SUFFIX.length, TRUNCATION_SUFFIX)
62+
}
63+
5264
/**
5365
* Reads a dot-delimited path off a parsed payload and returns it only when it
5466
* is a non-empty string, so blank metadata falls through to the next candidate.
@@ -59,7 +71,7 @@ function readString(source: unknown, path: string): string | undefined {
5971
if (!current || typeof current !== 'object') return undefined
6072
current = (current as Record<string, unknown>)[segment]
6173
}
62-
return typeof current === 'string' && current.trim() ? current : undefined
74+
return typeof current === 'string' && current.trim() ? current.trim() : undefined
6375
}
6476

6577
/**
@@ -83,10 +95,15 @@ function unwrapResponseEnvelope(payload: unknown): unknown {
8395
* the importer takes: the export envelope (`workflow.*`, `state.metadata.*`)
8496
* and a bare state (`metadata.*`).
8597
*
86-
* Payload-derived values are truncated to the same bounds the contract applies
87-
* to the explicit overrides — otherwise the declared `maxLength` would not be
88-
* the effective one, and a caller could store an unbounded name simply by
89-
* embedding it in the payload instead of passing it as a field.
98+
* Candidate order deliberately matches `extractWorkflowName` — the resolver the
99+
* in-app importer has always used — so the same payload yields the same name on
100+
* both surfaces. The in-app version additionally falls back to the uploaded
101+
* filename, which has no analogue here; that is the only intended difference.
102+
*
103+
* Payload-derived values are capped at the same bounds the contract applies to
104+
* the explicit overrides, otherwise the declared `maxLength` would not be the
105+
* effective one — a caller could store an unbounded name simply by embedding it
106+
* in the payload instead of passing it as a field.
90107
*/
91108
function resolveImportedMetadata(
92109
rawPayload: unknown,
@@ -97,19 +114,19 @@ function resolveImportedMetadata(
97114

98115
const name =
99116
overrideName ||
100-
truncate(
101-
readString(payload, 'workflow.name') ||
102-
readString(payload, 'state.metadata.name') ||
117+
capLength(
118+
readString(payload, 'state.metadata.name') ||
119+
readString(payload, 'workflow.name') ||
103120
readString(payload, 'metadata.name') ||
104121
DEFAULT_IMPORTED_WORKFLOW_NAME,
105122
V1_IMPORT_NAME_MAX_LENGTH
106123
)
107124

108125
const description =
109126
overrideDescription ??
110-
truncate(
111-
readString(payload, 'workflow.description') ??
112-
readString(payload, 'state.metadata.description') ??
127+
capLength(
128+
readString(payload, 'state.metadata.description') ??
129+
readString(payload, 'workflow.description') ??
113130
readString(payload, 'metadata.description') ??
114131
'',
115132
V1_IMPORT_DESCRIPTION_MAX_LENGTH

0 commit comments

Comments
 (0)