From b9342a5bb8e39c7dfd9555ed372cc618542b526a Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:46:19 +0000 Subject: [PATCH] fix(core): Detect invalid Slack Block Kit payloads in AI Assistant builds (#37217) Co-authored-by: Claude Opus 5 (1M context) --- .../reference/workflow-builder-guardrails.md | 11 ++ .../detect-slack-blocks-shape.test.ts | 156 ++++++++++++++++ .../workflow-source-compiler.test.ts | 55 ++++++ .../workflows/detect-slack-blocks-shape.ts | 168 ++++++++++++++++++ .../workflows/workflow-source-compiler.ts | 2 + .../nodes/Slack/V2/MessageDescription.ts | 4 +- .../Slack/test/v2/GenericFunctions.test.ts | 29 +++ 7 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 packages/@n8n/instance-ai/src/tools/workflows/__tests__/detect-slack-blocks-shape.test.ts create mode 100644 packages/@n8n/instance-ai/src/tools/workflows/detect-slack-blocks-shape.ts diff --git a/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md b/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md index 11170ad5331..2f5d408d3ec 100644 --- a/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md +++ b/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md @@ -119,6 +119,17 @@ expression there produce "Failed to parse schema" and crash the node before any output. Serialize the schema with double-quoted keys and strings, keep it minimal, and set the sibling `name` field. +## Slack Blocks Fields Take the Whole Block Kit Payload + +The Slack node's Blocks field (`blocksUi`) holds the **whole Block Kit payload +object** — `{ "blocks": [ ... ] }` — not the bare blocks array. The node reads +that value as an object and picks the blocks off its `blocks` key before +sending. A top-level `[ { "type": "section" }, ... ]` leaves that key +undefined, so Slack gets a body with no blocks, replies `ok: true`, and the +message renders empty — a silent failure the run never reports. Also set +`messageType: "block"`; with any other message type the payload is dropped and +only the plain text is posted. + ## Data After Side-Effect Nodes Send/notify/write nodes (Gmail, Slack, Telegram, email send, most "create" diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/detect-slack-blocks-shape.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/detect-slack-blocks-shape.test.ts new file mode 100644 index 00000000000..23215bfdf09 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/detect-slack-blocks-shape.test.ts @@ -0,0 +1,156 @@ +import type { WorkflowJSON } from '@n8n/workflow-sdk'; +import type { IDataObject } from 'n8n-workflow'; + +import { detectSlackBlocksShape } from '../detect-slack-blocks-shape'; + +function workflow( + parameters: Record, + type = 'n8n-nodes-base.slack', + typeVersion = 2.3, +): WorkflowJSON { + return { + id: 'wf-test', + name: 'Test', + nodes: [ + { + id: '1', + name: 'Post Lunch Train', + type, + typeVersion, + position: [0, 0], + parameters: parameters as IDataObject, + }, + ], + connections: {}, + }; +} + +function blockMessage(blocksUi: unknown, overrides: Record = {}) { + return { + resource: 'message', + operation: 'post', + select: 'channel', + messageType: 'block', + blocksUi, + ...overrides, + }; +} + +const BLOCKS = [ + { type: 'section', text: { type: 'mrkdwn', text: 'Lunch train departs at 12:30' } }, + { type: 'divider' }, +]; + +describe('detectSlackBlocksShape', () => { + const codes = (w: WorkflowJSON) => detectSlackBlocksShape(w).map((x) => x.code); + + it('flags a blocksUi JSON string that parses to a bare array', () => { + const warnings = detectSlackBlocksShape(workflow(blockMessage(JSON.stringify(BLOCKS)))); + + expect(warnings).toHaveLength(1); + expect(warnings[0].code).toBe('SLACK_BLOCKS_SHAPE_INVALID'); + expect(warnings[0].nodeName).toBe('Post Lunch Train'); + expect(warnings[0].message).toContain('{ "blocks": [ ... ] }'); + }); + + it('flags a blocksUi stored as a bare array value', () => { + expect(codes(workflow(blockMessage(BLOCKS)))).toEqual(['SLACK_BLOCKS_SHAPE_INVALID']); + }); + + it('flags a blocksUi object that has no blocks key', () => { + expect(codes(workflow(blockMessage({ text: 'Lunch train' })))).toEqual([ + 'SLACK_BLOCKS_SHAPE_INVALID', + ]); + }); + + it('flags a blocksUi object whose blocks value is not an array', () => { + expect(codes(workflow(blockMessage({ blocks: { type: 'section' } })))).toEqual([ + 'SLACK_BLOCKS_SHAPE_INVALID', + ]); + }); + + it('flags a blocksUi string that is not valid JSON', () => { + const warnings = detectSlackBlocksShape(workflow(blockMessage('[{ "type": "section" '))); + + expect(warnings).toHaveLength(1); + expect(warnings[0].code).toBe('SLACK_BLOCKS_SHAPE_INVALID'); + }); + + it('accepts a blocksUi JSON string wrapped in a blocks object', () => { + expect(codes(workflow(blockMessage(JSON.stringify({ blocks: BLOCKS }))))).toEqual([]); + }); + + it('accepts a blocksUi stored as a blocks object value', () => { + expect(codes(workflow(blockMessage({ blocks: BLOCKS })))).toEqual([]); + }); + + it('accepts an empty blocks array', () => { + expect(codes(workflow(blockMessage({ blocks: [] })))).toEqual([]); + }); + + // The real builder writes blocksUi as an `=` expression whose body is the JSON + // with `{{ }}` interpolations (observed while calibrating the eval case), so the + // bare-array mistake shows up in expression form too. + it('flags an expression whose body is a bare array', () => { + const expr = '=' + JSON.stringify(BLOCKS); + + expect(codes(workflow(blockMessage(expr)))).toEqual(['SLACK_BLOCKS_SHAPE_INVALID']); + }); + + it('accepts an expression whose body wraps the blocks, interpolations and all', () => { + const expr = + '={ "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "Departs {{ $json.departure }}" } } ] }'; + + expect(codes(workflow(blockMessage(expr)))).toEqual([]); + }); + + it('ignores an expression that is wholly dynamic, since its value is unknowable', () => { + expect(codes(workflow(blockMessage('={{ $json.slackPayload }}')))).toEqual([]); + expect(codes(workflow(blockMessage('={{ JSON.stringify($json.p) }}')))).toEqual([]); + }); + + it('ignores a blocks value that is an expression', () => { + expect(codes(workflow(blockMessage({ blocks: '={{ $json.blocks }}' })))).toEqual([]); + }); + + it('ignores a node with no blocksUi set', () => { + expect(codes(workflow(blockMessage('')))).toEqual([]); + expect( + codes(workflow({ resource: 'message', operation: 'post', messageType: 'block' })), + ).toEqual([]); + }); + + it('ignores non-Slack nodes', () => { + expect( + codes(workflow(blockMessage(JSON.stringify(BLOCKS)), 'n8n-nodes-base.slackTrigger')), + ).toEqual([]); + }); + + it('ignores Slack V1 nodes, whose blocksUi is a fixedCollection', () => { + const v1Params = { + resource: 'message', + operation: 'post', + jsonParameters: false, + blocksUi: { blocksValues: [{ type: 'section' }] }, + }; + + expect(codes(workflow(v1Params, 'n8n-nodes-base.slack', 1))).toEqual([]); + }); + + it('flags blocks built for a message that is not sent as blocks', () => { + const warnings = detectSlackBlocksShape( + workflow(blockMessage(JSON.stringify({ blocks: BLOCKS }), { messageType: 'text' })), + ); + + expect(warnings).toHaveLength(1); + expect(warnings[0].code).toBe('SLACK_BLOCKS_NOT_SENT'); + }); + + it('checks the update operation as well as post and schedule', () => { + for (const operation of ['post', 'schedule', 'update']) { + expect(codes(workflow(blockMessage(JSON.stringify(BLOCKS), { operation })))).toEqual([ + 'SLACK_BLOCKS_SHAPE_INVALID', + ]); + } + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-compiler.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-compiler.test.ts index 267e549ce5e..afa80b510ad 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-compiler.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-compiler.test.ts @@ -402,3 +402,58 @@ describe('compileWorkflowSource credential resolution', () => { expect(warnings).toHaveLength(0); }); }); + +describe('compileWorkflowSource > Slack Block Kit shape', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateWorkflow).mockReturnValue({ valid: true, errors: [], warnings: [] }); + }); + + async function compileSlack(blocksUi: unknown) { + const workflow = { + name: 'Slack workflow', + nodes: [ + { + id: 'slack-1', + name: 'Post Lunch Train', + type: 'n8n-nodes-base.slack', + typeVersion: 2.3, + position: [0, 0] as [number, number], + parameters: { resource: 'message', operation: 'post', messageType: 'block', blocksUi }, + }, + ], + connections: {}, + }; + + const result = await compileWorkflowSource( + makeContext(), + 'src/workflows/slack.workflow.json', + JSON.stringify(workflow), + ); + if (!result.success) throw new Error('expected compile success'); + return result.warnings; + } + + it('surfaces a warning for a bare Block Kit array', async () => { + const warnings = await compileSlack( + JSON.stringify([{ type: 'section', text: { type: 'mrkdwn', text: 'Departs 12:30' } }]), + ); + + expect(warnings).toEqual([ + expect.objectContaining({ + code: 'SLACK_BLOCKS_SHAPE_INVALID', + nodeName: 'Post Lunch Train', + }), + ]); + }); + + it('stays quiet for a wrapped Block Kit payload', async () => { + const warnings = await compileSlack( + JSON.stringify({ + blocks: [{ type: 'section', text: { type: 'mrkdwn', text: 'Departs 12:30' } }], + }), + ); + + expect(warnings).toEqual([]); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/detect-slack-blocks-shape.ts b/packages/@n8n/instance-ai/src/tools/workflows/detect-slack-blocks-shape.ts new file mode 100644 index 00000000000..591dbe91a3e --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/detect-slack-blocks-shape.ts @@ -0,0 +1,168 @@ +import { isRecord } from '@n8n/utils/is-record'; +import type { WorkflowJSON } from '@n8n/workflow-sdk'; + +import type { ValidationWarning } from './workflow-validation-warnings'; + +const SLACK_NODE_TYPE = 'n8n-nodes-base.slack'; +/** V1 stores `blocksUi` as a fixedCollection and has no `messageType`, so it is out of scope. */ +const MIN_TYPE_VERSION = 2; +const BLOCK_CAPABLE_OPERATIONS = ['post', 'schedule', 'update']; + +const WRAPPER_HINT = + 'The Blocks field must hold the whole Block Kit payload object — { "blocks": [ ... ] } — not the bare blocks array.'; + +// n8n treats a value as an expression only when its FIRST character is '='. +function isExpression(value: unknown): boolean { + return typeof value === 'string' && value.startsWith('='); +} + +type ResolvedBlocksUi = + | { kind: 'skip' } + | { kind: 'unparseable'; error: string } + | { kind: 'value'; value: unknown }; + +/** `{{ ... }}` interpolation segments inside an expression body. */ +const INTERPOLATION = /\{\{[\s\S]*?\}\}/g; +const INTERPOLATION_PLACEHOLDER = 'n8n-expression'; + +/** + * The builder usually writes the payload as an `=` expression whose body is the + * literal JSON with `{{ }}` holes, so the wrapper mistake shows up there too. + * Blanking the holes keeps such a body parseable; a wholly dynamic expression + * stops parsing and is left alone, since its value is unknowable here. + */ +function resolveExpressionBody(raw: string): ResolvedBlocksUi { + const body = raw.slice(1).replace(INTERPOLATION, INTERPOLATION_PLACEHOLDER); + try { + return { kind: 'value', value: JSON.parse(body) }; + } catch { + return { kind: 'skip' }; + } +} + +/** + * Mirrors the node's `ensureType: 'object'` read of `blocksUi`: a JSON string is + * parsed, anything else is passed through as-is. + */ +function resolveBlocksUi(raw: unknown): ResolvedBlocksUi { + if (raw === undefined || raw === null) return { kind: 'skip' }; + if (typeof raw === 'string') { + if (raw.trim().length === 0) return { kind: 'skip' }; + if (isExpression(raw)) return resolveExpressionBody(raw); + try { + return { kind: 'value', value: JSON.parse(raw) }; + } catch (error) { + return { kind: 'unparseable', error: error instanceof Error ? error.message : 'parse error' }; + } + } + // An empty fixedCollection-style default carries no blocks to check. + if (isRecord(raw) && Object.keys(raw).length === 0) return { kind: 'skip' }; + return { kind: 'value', value: raw }; +} + +function describe(value: unknown): string { + if (Array.isArray(value)) return 'an array'; + if (value === null) return 'null'; + return `a ${typeof value}`; +} + +/** + * Flags Slack message nodes whose Block Kit config cannot render at runtime. + * + * The node reads `blocksUi` as an object and sends it to Slack as the request + * body, picking blocks off `content.blocks`. A bare `[ ... ]` array — the shape + * the builder reaches for by default — leaves `blocks` undefined, so Slack gets + * a body with no blocks, returns `ok: true`, and renders nothing. Same silent + * outcome when the blocks live under some other key, or when the node is set to + * send text while carrying a blocks payload. + */ +export function detectSlackBlocksShape(json: WorkflowJSON): ValidationWarning[] { + const warnings: ValidationWarning[] = []; + + for (const node of json.nodes ?? []) { + if (node.type !== SLACK_NODE_TYPE) continue; + if (typeof node.typeVersion !== 'number' || node.typeVersion < MIN_TYPE_VERSION) continue; + const params = node.parameters; + if (!isRecord(params)) continue; + + const resource = params.resource ?? 'message'; + const operation = params.operation ?? 'post'; + if (resource !== 'message') continue; + if (typeof operation !== 'string' || !BLOCK_CAPABLE_OPERATIONS.includes(operation)) continue; + + const messageType = params.messageType ?? 'text'; + // A non-string messageType is malformed config the schema validator already reports. + if (typeof messageType !== 'string' || isExpression(messageType)) continue; + + const resolved = resolveBlocksUi(params.blocksUi); + if (resolved.kind === 'skip') continue; + + const nodeName = typeof node.name === 'string' ? node.name : undefined; + + if (messageType !== 'block') { + warnings.push({ + code: 'SLACK_BLOCKS_NOT_SENT', + nodeName, + message: + `Slack node carries a Blocks payload but its Message Type is "${messageType}", ` + + 'so the blocks are dropped and only the plain text is posted. ' + + 'Set messageType to "block" to send them.', + }); + continue; + } + + if (resolved.kind === 'unparseable') { + warnings.push({ + code: 'SLACK_BLOCKS_SHAPE_INVALID', + nodeName, + message: + `Slack node Blocks field is not valid JSON (${resolved.error}). ` + + 'The node parses it before sending and throws "could not be parsed" at runtime. ' + + WRAPPER_HINT, + }); + continue; + } + + const content = resolved.value; + if (!isRecord(content) || Array.isArray(content)) { + warnings.push({ + code: 'SLACK_BLOCKS_SHAPE_INVALID', + nodeName, + message: + `Slack node Blocks field holds ${describe(content)}. ` + + 'The node reads blocks off the "blocks" key of that value, so nothing is sent to Slack, ' + + 'the call still returns ok, and the message renders empty. ' + + WRAPPER_HINT, + }); + continue; + } + + const blocks = content.blocks; + if (isExpression(blocks)) continue; + + if (blocks === undefined) { + warnings.push({ + code: 'SLACK_BLOCKS_SHAPE_INVALID', + nodeName, + message: + `Slack node Blocks field has no "blocks" key (found: ${Object.keys(content).join(', ') || 'nothing'}). ` + + 'Slack receives a body with no blocks, returns ok, and the message renders empty. ' + + WRAPPER_HINT, + }); + continue; + } + + if (!Array.isArray(blocks)) { + warnings.push({ + code: 'SLACK_BLOCKS_SHAPE_INVALID', + nodeName, + message: + `Slack node Blocks field has a "blocks" value that is ${describe(blocks)}, not an array. ` + + 'Slack rejects or ignores it and the message renders empty. ' + + WRAPPER_HINT, + }); + } + } + + return warnings; +} diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts index 11787285de1..255a14a8ba0 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts @@ -6,6 +6,7 @@ import { normalizeNodeShape } from 'n8n-workflow'; import { buildCredentialHostIndex, resolveCredentialByUrl } from './credential-url-resolver'; import { detectArrayInputCollapse } from './detect-array-input-collapse'; +import { detectSlackBlocksShape } from './detect-slack-blocks-shape'; import { detectUnparseableOpenAiSchema } from './detect-unparseable-openai-schema'; import { detectWrongKindLocatorValues } from './detect-wrong-kind-locator'; import { collectValidationIssues, type ValidationWarning } from './workflow-validation-warnings'; @@ -92,6 +93,7 @@ function validateCompiledWorkflow( warnings.push(...detectArrayInputCollapse(json)); warnings.push(...detectWrongKindLocatorValues(json, context.nodeTypesProvider)); warnings.push(...detectUnparseableOpenAiSchema(json)); + warnings.push(...detectSlackBlocksShape(json)); return warnings; } diff --git a/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts b/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts index 4c3714e9e85..6ea00ab0b33 100644 --- a/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts +++ b/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts @@ -506,7 +506,7 @@ export const messageFields: INodeProperties[] = [ rows: 3, }, description: - "Enter the JSON output from Slack's visual Block Kit Builder here. You can then use expressions to add variable content to your blocks. To create blocks, use Slack's Block Kit Builder", + "Enter the JSON output from Slack's visual Block Kit Builder here, including the top-level wrapper: { \"blocks\": [ ... ] }. A bare array of blocks is not accepted. You can then use expressions to add variable content to your blocks. To create blocks, use Slack's Block Kit Builder", hint: "To create blocks, use Slack's Block Kit Builder", default: '', }, @@ -1088,7 +1088,7 @@ export const messageFields: INodeProperties[] = [ rows: 3, }, description: - "Enter the JSON output from Slack's visual Block Kit Builder here. You can then use expressions to add variable content to your blocks. To create blocks, use Slack's Block Kit Builder", + "Enter the JSON output from Slack's visual Block Kit Builder here, including the top-level wrapper: { \"blocks\": [ ... ] }. A bare array of blocks is not accepted. You can then use expressions to add variable content to your blocks. To create blocks, use Slack's Block Kit Builder", hint: "To create blocks, use Slack's Block Kit Builder", default: '', }, diff --git a/packages/nodes-base/nodes/Slack/test/v2/GenericFunctions.test.ts b/packages/nodes-base/nodes/Slack/test/v2/GenericFunctions.test.ts index a03c9969ec6..9c45ec3b367 100644 --- a/packages/nodes-base/nodes/Slack/test/v2/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Slack/test/v2/GenericFunctions.test.ts @@ -1397,6 +1397,35 @@ describe('Slack V2 > GenericFunctions', () => { }); }); + // A bare Block Kit array (`[ {...} ]`) is what `ensureType: 'object'` yields for a + // blocksUi string holding the array without the `{ blocks: [...] }` wrapper. Slack + // then gets a body with no `blocks` key and renders nothing, without erroring. + it('should drop the blocks when blocksUi is a bare array instead of a blocks object', () => { + const mockBlocksUI = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'Hello World', + }, + }, + ]; + + (mockExecuteFunctions.getNodeParameter as Mock).mockImplementation( + (param: string, _index: number) => { + if (param === 'messageType') return 'block'; + if (param === 'otherOptions.includeLinkToWorkflow') return false; + if (param === 'text') return 'Fallback text'; + if (param === 'blocksUi') return mockBlocksUI; + return undefined; + }, + ); + + const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123'); + + expect(result.blocks).toBeUndefined(); + }); + it('should add text property when text parameter is provided', () => { const mockBlocksUI = { blocks: [],