fix(core): Detect invalid Slack Block Kit payloads in AI Assistant builds (#37217)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour 2026-08-27 12:46:19 +00:00 committed by GitHub
parent f9814b753f
commit b9342a5bb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 423 additions and 2 deletions

View File

@ -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"

View File

@ -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<string, unknown>,
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<string, unknown> = {}) {
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',
]);
}
});
});

View File

@ -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([]);
});
});

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 <a target='_blank' href='https://app.slack.com/block-kit-builder'>Slack's Block Kit Builder</a>",
"Enter the JSON output from Slack's visual Block Kit Builder here, including the top-level wrapper: <code>{ \"blocks\": [ ... ] }</code>. A bare array of blocks is not accepted. You can then use expressions to add variable content to your blocks. To create blocks, use <a target='_blank' href='https://app.slack.com/block-kit-builder'>Slack's Block Kit Builder</a>",
hint: "To create blocks, use <a target='_blank' href='https://app.slack.com/block-kit-builder'>Slack's Block Kit Builder</a>",
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 <a target='_blank' href='https://app.slack.com/block-kit-builder'>Slack's Block Kit Builder</a>",
"Enter the JSON output from Slack's visual Block Kit Builder here, including the top-level wrapper: <code>{ \"blocks\": [ ... ] }</code>. A bare array of blocks is not accepted. You can then use expressions to add variable content to your blocks. To create blocks, use <a target='_blank' href='https://app.slack.com/block-kit-builder'>Slack's Block Kit Builder</a>",
hint: "To create blocks, use <a target='_blank' href='https://app.slack.com/block-kit-builder'>Slack's Block Kit Builder</a>",
default: '',
},

View File

@ -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: [],