fix(core): Fail publication of stored versions with trigger node ids over 64 characters (#41519)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Florian Gößler
2026-10-23 14:26:33 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 multica-agent Cursor Agent
parent b46aba4a1a
commit 26c580e320
11 changed files with 372 additions and 10 deletions
+6
View File
@@ -11,6 +11,12 @@ export const BUILTIN_NODES_PACKAGES = ['n8n-nodes-base', '@n8n/n8n-nodes-langcha
*/
export const NODE_ID_MAX_LENGTH = 64;
/**
* Whether `nodeId` is too long to store in the columns keyed on node ids.
* Counts unicode code points to match PostgreSQL's `varchar(n)` behavior on UTF-8.
*/
export const isNodeIdTooLong = (nodeId: string): boolean => [...nodeId].length > NODE_ID_MAX_LENGTH;
/**
* Built-in node types that need a backend module to run, keyed to that module.
* The node panel and the AI builder offer them only while the module is enabled.
+4 -1
View File
@@ -180,5 +180,8 @@ export const SCHEDULED_JOB_OWNER_TYPE_MAX_LENGTH = 32;
/** Longest accepted `ownerId`. */
export const SCHEDULED_JOB_OWNER_ID_MAX_LENGTH = 255;
/** Longest accepted `ownerMemberId`: a workflow node id, or an agent task id (32 characters). */
/** Longest accepted `ownerMemberId`:
* - a workflow node id (64 characters - see NODE_ID_MAX_LENGTH),
* - or an agent task id (32 characters - see AGENT_TASK_ID_MAX_LENGTH).
*/
export const SCHEDULED_JOB_OWNER_MEMBER_ID_MAX_LENGTH = NODE_ID_MAX_LENGTH;
@@ -2,6 +2,7 @@ import {
SCHEDULED_JOB_NAME_MAX_LENGTH,
SCHEDULED_JOB_OWNER_ID_MAX_LENGTH,
SCHEDULED_JOB_OWNER_MEMBER_ID_MAX_LENGTH,
isNodeIdTooLong,
} from '@n8n/constants';
import { deprovision, provision } from './provision';
@@ -128,7 +129,7 @@ function assertProvisionableOwner(
}
if (ownerMemberId === null) return;
const ownerMemberIdLength = characterCount(ownerMemberId);
if (ownerMemberIdLength === 0 || ownerMemberIdLength > SCHEDULED_JOB_OWNER_MEMBER_ID_MAX_LENGTH) {
if (ownerMemberIdLength === 0 || isNodeIdTooLong(ownerMemberId)) {
throw new InvalidOwnerMemberIdError(
ownerMemberIdLength,
SCHEDULED_JOB_OWNER_MEMBER_ID_MAX_LENGTH,
@@ -1,3 +1,4 @@
import { NODE_ID_MAX_LENGTH } from '@n8n/constants';
import type { CredentialsRepository, User, WorkflowRepository } from '@n8n/db';
import type {
INode,
@@ -1774,6 +1775,27 @@ describe('WorkflowValidationService', () => {
expect(result.error).toContain('Cannot publish workflow');
expect(result.error).toContain('"Webhook"');
});
it('rejects a trigger node whose id is longer than NODE_ID_MAX_LENGTH', () => {
const result = service.validateTriggerNodeIds([
triggerNode('Cron', 'n'.repeat(NODE_ID_MAX_LENGTH)),
triggerNode('Webhook', 'n'.repeat(NODE_ID_MAX_LENGTH + 1)),
]);
expect(result).toEqual({
isValid: false,
error:
'Cannot publish workflow: trigger "Webhook" has a node ID longer than 64 characters. Remove and re-add the affected nodes to give them new IDs.',
});
});
it('counts trigger node id length in characters, not UTF-16 code units', () => {
const result = service.validateTriggerNodeIds([
triggerNode('Cron', '😀'.repeat(NODE_ID_MAX_LENGTH)),
]);
expect(result).toEqual({ isValid: true });
});
});
describe('validateRequiredInputsConnected', () => {
const nodeTypes = mock<NodeTypes>();
@@ -15,6 +15,7 @@ import { PolicyViolationError } from '@/policy/policy-violation.error';
import type { Push } from '@/push';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { PublicationStatusReporter } from '@/workflows/publication/publication-status-reporter';
import { TriggerNodeIdTooLongError } from '@/workflows/publication/trigger-node-id-too-long.error';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import type { WorkflowSharingService } from '@n8n/backend-services';
@@ -480,6 +481,61 @@ describe('PublicationStatusReporter', () => {
);
});
test('failed for one oversized trigger pushes that trigger node id', async () => {
const error = new TriggerNodeIdTooLongError([{ id: 'too-long', name: 'Long' }]);
await reporter.report(makeRecord(), {
type: 'failed',
error,
triggerStatuses: [
{
nodeId: 'fitting',
nodeName: 'Fitting',
status: 'failed',
triggerKind: 'in-memory',
errorMessage: error.message,
},
],
});
expect(push.sendToUsers).toHaveBeenCalledWith(
{
type: 'workflowFailedToActivate',
data: { workflowId: 'wf-1', errorMessage: error.message, nodeId: 'too-long' },
},
userIds,
);
});
test('failed for several oversized triggers pushes no node id', async () => {
const error = new TriggerNodeIdTooLongError([
{ id: 'first-long', name: 'First' },
{ id: 'second-long', name: 'Second' },
]);
await reporter.report(makeRecord(), {
type: 'failed',
error,
triggerStatuses: [
{
nodeId: 'fitting',
nodeName: 'Fitting',
status: 'failed',
triggerKind: 'in-memory',
errorMessage: error.message,
},
],
});
expect(push.sendToUsers).toHaveBeenCalledWith(
{
type: 'workflowFailedToActivate',
data: { workflowId: 'wf-1', errorMessage: error.message },
},
userIds,
);
});
// A policy refusal marks every trigger failed with the policy error, so the
// one failed row is not the node whose own error this is.
test('failed by policy with one failed row pushes no nodeId', async () => {
@@ -8,6 +8,7 @@ import type {
WorkflowRepository,
} from '@n8n/db';
import type { Logger } from '@n8n/backend-common';
import { NODE_ID_MAX_LENGTH } from '@n8n/constants';
import { mock } from 'vitest-mock-extended';
import { UnrecognizedNodeTypeError } from 'n8n-core';
import type { INode, INodeType } from 'n8n-workflow';
@@ -125,11 +126,11 @@ describe('WorkflowPublicationApplier', () => {
const abort = { signal: new AbortController().signal, onDetached: vi.fn() };
/** Drives the trigger diff: first call returns old triggers, second returns new. */
/** Drives the trigger diff: `oldVersion` (or none) returns old triggers, any other version returns new. */
function setTriggerSets(oldTriggers: INode[], newTriggers: INode[]) {
workflowTriggerActivator.getEnabledTriggerNodes
.mockReturnValueOnce(oldTriggers)
.mockReturnValueOnce(newTriggers);
workflowTriggerActivator.getEnabledTriggerNodes.mockImplementation((version) =>
version === null || version === oldVersion ? oldTriggers : newTriggers,
);
}
beforeEach(() => {
@@ -1473,4 +1474,132 @@ describe('WorkflowPublicationApplier', () => {
expect(result).toEqual({ type: 'skipped', reason: 'node-ids-healed' });
});
});
describe('trigger node ids longer than the column width', () => {
const longId = 'x'.repeat(NODE_ID_MAX_LENGTH + 1);
const fittingId = 'y'.repeat(NODE_ID_MAX_LENGTH);
const setNode = (id: string): INode =>
triggerNode(id, { name: 'Set', type: 'n8n-nodes-base.set' });
const versionWith = (nodes: INode[]): WorkflowHistory =>
({ ...makeVersion('v-2'), nodes }) as WorkflowHistory;
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockImplementation((type: string) => {
if (type === 'n8n-nodes-base.executeCommand') {
throw new UnrecognizedNodeTypeError('n8n-nodes-base', 'executeCommand');
}
return type.toLowerCase().includes('trigger')
? ({ trigger: async () => ({}) } as unknown as INodeType)
: ({} as INodeType);
});
});
afterEach(() => {
nodeTypes.getByNameAndVersion.mockReset();
});
test('fails before touching triggers, and reports only the triggers whose id fits', async () => {
const longTrigger = triggerNode(longId, { name: 'Long' });
const fittingTrigger = triggerNode(fittingId, { name: 'Fitting' });
workflowHistoryRepository.findOneBy.mockResolvedValue(
versionWith([longTrigger, fittingTrigger]),
);
workflowTriggerActivator.getEnabledTriggerNodes.mockReturnValue([
longTrigger,
fittingTrigger,
]);
const result = await applier.apply(makeRecord(), abort);
const message = `Cannot publish workflow: trigger "Long" has a node ID longer than ${NODE_ID_MAX_LENGTH} characters. Remove and re-add the affected nodes to give them new IDs.`;
expect(result).toEqual({
type: 'failed',
error: expect.objectContaining({
message,
affectedNodes: [{ id: longId, name: 'Long' }],
}),
triggerStatuses: [
{
nodeId: fittingId,
nodeName: 'Fitting',
status: 'failed',
triggerKind: 'in-memory',
errorMessage: message,
},
],
});
expect(workflowPublishedVersionRepository.setPublishedVersion).not.toHaveBeenCalled();
expect(workflowTriggerActivator.activate).not.toHaveBeenCalled();
expect(workflowTriggerActivator.deactivate).not.toHaveBeenCalled();
expect(policyEnforcementService.enforceWorkflowPublish).not.toHaveBeenCalled();
});
test('counts node id length in characters, not UTF-16 code units', async () => {
const astralId = '😀'.repeat(NODE_ID_MAX_LENGTH);
const longTrigger = triggerNode(longId, { name: 'Long' });
const astralTrigger = triggerNode(astralId, { name: 'Astral' });
workflowHistoryRepository.findOneBy.mockResolvedValue(
versionWith([longTrigger, astralTrigger]),
);
workflowTriggerActivator.getEnabledTriggerNodes.mockReturnValue([longTrigger, astralTrigger]);
const result = await applier.apply(makeRecord(), abort);
expect(result).toMatchObject({
type: 'failed',
error: { affectedNodes: [{ id: longId, name: 'Long' }] },
triggerStatuses: [{ nodeId: astralId, nodeName: 'Astral', status: 'failed' }],
});
});
test('names every trigger whose id is too long', async () => {
const triggers = [
triggerNode(longId, { name: 'First' }),
triggerNode(`${longId}z`, { name: 'Second' }),
];
workflowHistoryRepository.findOneBy.mockResolvedValue(versionWith(triggers));
workflowTriggerActivator.getEnabledTriggerNodes.mockReturnValue(triggers);
const result = await applier.apply(makeRecord(), abort);
expect(result).toMatchObject({
type: 'failed',
error: expect.objectContaining({
message: expect.stringContaining('triggers "First", "Second" have node IDs longer than'),
affectedNodes: [
{ id: longId, name: 'First' },
{ id: `${longId}z`, name: 'Second' },
],
}),
});
});
test('fails when a node whose type cannot load has a long id, since it gets its own row', async () => {
workflowHistoryRepository.findOneBy.mockResolvedValue(
versionWith([
triggerNode(longId, { name: 'Execute Command', type: 'n8n-nodes-base.executeCommand' }),
]),
);
const result = await applier.apply(makeRecord(), abort);
expect(result).toMatchObject({ type: 'failed', triggerStatuses: [] });
expect(workflowPublishedVersionRepository.setPublishedVersion).not.toHaveBeenCalled();
});
test.each([
['a non-trigger node', setNode(longId)],
['a disabled trigger', triggerNode(longId, { disabled: true })],
])('publishes a version where only %s has a long id', async (_, node) => {
workflowHistoryRepository.findOneBy.mockResolvedValue(versionWith([node]));
const result = await applier.apply(makeRecord(), abort);
expect(result.type).toBe('completed');
expect(workflowPublishedVersionRepository.setPublishedVersion).toHaveBeenCalledWith(
'wf-1',
'v-2',
);
});
});
});
@@ -23,6 +23,7 @@ import type {
PublicationSkipReason,
TriggerPublicationStatus,
} from '@/workflows/publication/publication-result';
import { TriggerNodeIdTooLongError } from '@/workflows/publication/trigger-node-id-too-long.error';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import type { TriggerTeardownFailure } from '@/workflows/triggers/workflow-trigger-activator';
@@ -127,11 +128,16 @@ export class PublicationStatusReporter {
if (!policyRefusal) {
this.errorReporter.error(result.error, { shouldBeLogged: true });
}
// Name the node only when one trigger failed with its own error: the FE prefixes the toast with it.
// A policy refusal fails every trigger with the same error, so it names none.
// Name a node only when the error belongs to it. Shared failures name none.
const failedTriggers = (triggerStatuses ?? []).filter((s) => s.status === 'failed');
const nodeId =
!policyRefusal && failedTriggers.length === 1 ? failedTriggers[0].nodeId : undefined;
let nodeId: string | undefined;
if (result.error instanceof TriggerNodeIdTooLongError) {
if (result.error.affectedNodes.length === 1) {
nodeId = result.error.affectedNodes[0]?.id;
}
} else if (!policyRefusal && failedTriggers.length === 1) {
nodeId = failedTriggers[0].nodeId;
}
await this.pushFailedToActivate(record.workflowId, result.error.message, nodeId);
return;
}
@@ -0,0 +1,19 @@
import { NODE_ID_MAX_LENGTH } from '@n8n/constants';
import { UserError, type INode } from 'n8n-workflow';
export class TriggerNodeIdTooLongError extends UserError {
readonly affectedNodes: ReadonlyArray<Readonly<Pick<INode, 'id' | 'name'>>>;
constructor(nodes: ReadonlyArray<Pick<INode, 'id' | 'name'>>) {
const affectedNodes = nodes.map(({ id, name }) => ({ id, name }));
const names = affectedNodes.map((node) => `"${node.name}"`).join(', ');
const subject =
affectedNodes.length === 1
? `trigger ${names} has a node ID`
: `triggers ${names} have node IDs`;
super(
`Cannot publish workflow: ${subject} longer than ${NODE_ID_MAX_LENGTH} characters. Remove and re-add the affected nodes to give them new IDs.`,
);
this.affectedNodes = affectedNodes;
}
}
@@ -1,4 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { isNodeIdTooLong } from '@n8n/constants';
import {
WorkflowEntity,
WorkflowHistory,
@@ -32,6 +33,7 @@ import type {
TriggerPublicationStatus,
} from '@/workflows/publication/publication-result';
import { computeTriggerDiff } from '@/workflows/publication/trigger-diff';
import { TriggerNodeIdTooLongError } from '@/workflows/publication/trigger-node-id-too-long.error';
import {
isTriggerLikeNodeType,
type WorkflowTriggerVersion,
@@ -155,6 +157,9 @@ export class WorkflowPublicationApplier {
const healSkip = await this.healBrokenNodeIds(workflow, newVersion);
if (healSkip !== null) return healSkip;
const oversized = this.rejectOversizedTriggerNodeIds(workflow, newVersion);
if (oversized !== null) return oversized;
const unrunnable = await this.handleUnrunnableVersion(
workflow,
oldVersion,
@@ -705,6 +710,44 @@ export class WorkflowPublicationApplier {
return { type: 'skipped', reason: published.published ? 'node-ids-healed' : 'superseded' };
}
/**
* Fails a version whose trigger node ids cannot be stored in status rows. This
* runs before trigger changes, so the old version keeps running. Fitting ids
* get failed rows to prevent reconciliation retries.
*/
private rejectOversizedTriggerNodeIds(
workflow: WorkflowEntity,
newVersion: WorkflowHistory,
): PublicationResult | null {
const { runnable, unresolvable } = this.partitionByResolvability(newVersion);
const enabledTriggerNodes = this.workflowTriggerActivator.getEnabledTriggerNodes(runnable);
const oversized = [...enabledTriggerNodes, ...unresolvable.map(({ node }) => node)].filter(
(node) => isNodeIdTooLong(node.id),
);
if (oversized.length === 0) return null;
this.logger.warn('Published version carries trigger node ids that are too long', {
workflowId: workflow.id,
versionId: newVersion.versionId,
nodeNames: oversized.map((node) => node.name),
});
const error = new TriggerNodeIdTooLongError(oversized);
const storable = enabledTriggerNodes.filter((node) => !isNodeIdTooLong(node.id));
return {
type: 'failed',
error,
triggerStatuses: this.buildTriggerStatuses(
storable,
this.workflowTriggerActivator.getTriggerKinds(storable),
{
activated: [],
failures: storable.map((node) => ({ nodeId: node.id, nodeName: node.name, error })),
},
),
};
}
private isTriggerLikeNode(node: INode): boolean {
try {
return isTriggerLikeNodeType(this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion));
@@ -1,3 +1,4 @@
import { NODE_ID_MAX_LENGTH, isNodeIdTooLong } from '@n8n/constants';
import type { User } from '@n8n/db';
import { CredentialsRepository, WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
@@ -272,6 +273,9 @@ export class WorkflowValidationService {
*
* An absent id is the same defect as a shared one — `undefined` is just an id
* that every id-less node has in common — so both are reported together.
*
* An id longer than `NODE_ID_MAX_LENGTH` does not fit the columns keyed on it.
* External clients can supply such ids, so they are rejected here at publish time.
*/
validateTriggerNodeIds(triggerNodes: INode[]): WorkflowValidationResult {
const violations: string[] = [];
@@ -283,6 +287,12 @@ export class WorkflowValidationService {
continue;
}
if (isNodeIdTooLong(node.id)) {
violations.push(
`trigger "${node.name}" has a node ID longer than ${NODE_ID_MAX_LENGTH} characters`,
);
}
const names = namesById.get(node.id);
if (names) {
names.push(node.name);
@@ -270,6 +270,73 @@ describe('WorkflowPublicationOutboxConsumer (integration)', () => {
]);
});
test('completes the publication of a version whose only long node id is on a disabled trigger', async () => {
const owner = await createOwner();
const enabled = scheduleNode('enabled');
const disabled = {
...scheduleNode('disabled'),
id: 'n'.repeat(NODE_ID_MAX_LENGTH + 1),
disabled: true,
};
const workflow = await createWorkflowWithHistory(
{ active: true, nodes: [enabled, disabled] },
owner,
);
await setActiveVersion(workflow.id, workflow.versionId);
await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish');
const record = await outboxRepository.claimNextPendingRecord();
await consumer.processRecord(record!, abortSignal);
const row = await outboxRepository.findOneBy({ id: record!.id });
expect(row?.status).toBe('completed');
expect(activeWorkflowTriggers.get(workflow.id)?.has(enabled.id)).toBe(true);
expect(await triggerStatusRepository.findByWorkflowId(workflow.id)).toEqual([
expect.objectContaining({ nodeId: enabled.id, status: 'activated' }),
]);
});
test('fails a stored version with a trigger node id that is too long, and keeps the old triggers', async () => {
const owner = await createOwner();
const running = scheduleNode('running');
const workflow = await createWorkflowWithHistory({ active: true, nodes: [running] }, owner);
await setActiveVersion(workflow.id, workflow.versionId);
await publishedVersionRepository.setPublishedVersion(workflow.id, workflow.versionId);
await activeWorkflowManager.add(workflow.id, 'activate');
// Write the version directly, as an older release or an external client could.
const tooLong = { ...scheduleNode('too-long'), id: 'n'.repeat(NODE_ID_MAX_LENGTH + 1) };
const fitting = scheduleNode('fitting');
const newVersionId = uuid();
await createWorkflowHistoryItem(workflow.id, {
versionId: newVersionId,
nodes: [tooLong, fitting],
connections: {},
});
await setActiveVersion(workflow.id, newVersionId);
await outboxRepository.enqueue(workflow.id, newVersionId, 'publish');
const record = await outboxRepository.claimNextPendingRecord();
await consumer.processRecord(record!, abortSignal);
const row = await outboxRepository.findOneBy({ id: record!.id });
expect(row?.status).toBe('failed');
expect(row?.errorMessage).toContain(`longer than ${NODE_ID_MAX_LENGTH} characters`);
expect(activeWorkflowTriggers.get(workflow.id)?.has(running.id)).toBe(true);
expect(activeWorkflowTriggers.get(workflow.id)?.has(fitting.id)).toBe(false);
const published = await publishedVersionRepository.getPublishedVersionWithRelations(
workflow.id,
);
expect(published?.publishedVersionId).toBe(workflow.versionId);
expect(await triggerStatusRepository.findByWorkflowId(workflow.id)).toEqual([
expect.objectContaining({ nodeId: fitting.id, status: 'failed' }),
]);
});
test('publishes a healed version for duplicate trigger node ids, then activates it', async () => {
const owner = await createOwner();