diff --git a/packages/@n8n/workflow-sdk/src/types/base.ts b/packages/@n8n/workflow-sdk/src/types/base.ts index 9771ae4a8d1..58ad88aee6e 100644 --- a/packages/@n8n/workflow-sdk/src/types/base.ts +++ b/packages/@n8n/workflow-sdk/src/types/base.ts @@ -432,7 +432,7 @@ export interface WorkflowContext { */ export interface NodeConfig { parameters?: TParams; - credentials?: Record; + credentials?: Record; name?: string; position?: [number, number]; webhookId?: string; diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/node-builders/node-builder.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/node-builders/node-builder.test.ts index 078b6ae96a8..5a95ae123d8 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/node-builders/node-builder.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/node-builders/node-builder.test.ts @@ -445,6 +445,84 @@ describe('Node Builder', () => { }); }); + describe('placeholder() inside credentials slot', () => { + it('normalizes placeholder() to a __newCredential marker carrying the hint as name', () => { + const n = node({ + type: 'n8n-nodes-base.slack', + version: 2.2, + config: { + parameters: { channel: '#general' }, + credentials: { slackApi: placeholder('Slack Bot') }, + }, + }); + + const stored = n.config.credentials?.slackApi; + expect(stored).toBeDefined(); + expect((stored as { __newCredential?: boolean }).__newCredential).toBe(true); + expect((stored as { name?: string }).name).toBe('Slack Bot'); + expect((stored as { id?: string }).id).toBeUndefined(); + // The original __placeholder marker is gone — credentials maps never carry it. + expect((stored as { __placeholder?: boolean }).__placeholder).toBeUndefined(); + }); + + it('serializes a placeholder() credential to undefined (omitted from JSON)', () => { + const n = node({ + type: 'n8n-nodes-base.slack', + version: 2.2, + config: { + credentials: { slackApi: placeholder('Slack Bot') }, + }, + }); + + // Same shape as newCredential() without id: toJSON returns undefined + // so JSON.stringify drops the slot entirely. + expect(JSON.stringify(n.config.credentials)).toBe('{}'); + }); + + it('does not leak the <__PLACEHOLDER_VALUE__*> string into serialized credentials', () => { + const n = node({ + type: 'n8n-nodes-base.slack', + version: 2.2, + config: { + credentials: { slackApi: placeholder('Slack Bot') }, + }, + }); + + expect(JSON.stringify(n.config.credentials)).not.toContain('__PLACEHOLDER_VALUE__'); + }); + + it('normalizes only the placeholder slot, leaving other credentials untouched', () => { + const n = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { + credentials: { + httpBasicAuth: { id: 'existing-123', name: 'Existing Auth' }, + httpHeaderAuth: placeholder('Header Auth'), + }, + }, + }); + + const creds = n.config.credentials!; + expect(creds.httpBasicAuth).toEqual({ id: 'existing-123', name: 'Existing Auth' }); + expect((creds.httpHeaderAuth as { __newCredential?: boolean }).__newCredential).toBe(true); + expect((creds.httpHeaderAuth as { name?: string }).name).toBe('Header Auth'); + }); + + it('also normalizes when credentials are supplied via update()', () => { + const n = node({ + type: 'n8n-nodes-base.slack', + version: 2.2, + config: { parameters: { channel: '#general' } }, + }); + + const updated = n.update({ credentials: { slackApi: placeholder('Slack Bot') } }); + const stored = updated.config.credentials?.slackApi; + expect((stored as { __newCredential?: boolean }).__newCredential).toBe(true); + expect((stored as { name?: string }).name).toBe('Slack Bot'); + }); + }); + describe('then() with multiple targets (fan-out)', () => { it('should connect a node to multiple targets with array syntax', () => { const source = node({ diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/node-builders/node-builder.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/node-builders/node-builder.ts index b6caa8d4646..cf85b75aac0 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/node-builders/node-builder.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/node-builders/node-builder.ts @@ -11,6 +11,7 @@ import { type StickyNoteConfig, type PlaceholderValue, type NewCredentialValue, + type CredentialReference, type DeclaredConnection, type NodeChain, type InputTarget, @@ -98,6 +99,35 @@ function generateNodeName(type: string): string { .replace(/Gcp/g, 'GCP'); } +/** + * Collapse `placeholder('hint')` markers inside a credentials map into + * `newCredential('hint')`. The two have identical intent in this slot — + * "a credential is required, no real one is bound yet" — so we normalize at + * config ingest. Downstream code (resolveCredentials, hasNewCredential, the + * `__newCredential` toJSON path) only ever sees `__newCredential` markers in + * credential slots, never `__placeholder` ones. + * + * Returns a new config object when any normalization happens; otherwise a + * shallow copy (matching the previous `{ ...config }` semantics). + */ +export function normalizeNodeConfig(config: NodeConfig): NodeConfig { + const creds = config?.credentials; + if (!creds) return { ...config }; + + let normalizedCreds: + | Record + | undefined; + for (const [key, value] of Object.entries(creds)) { + if (value && typeof value === 'object' && '__placeholder' in value) { + normalizedCreds ??= { ...creds }; + normalizedCreds[key] = new NewCredentialImpl(value.hint); + } + } + + if (!normalizedCreds) return { ...config }; + return { ...config, credentials: normalizedCreds }; +} + /** * Internal node instance implementation */ @@ -122,7 +152,7 @@ class NodeInstanceImpl): boolean { // Check main node credentials diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/serializers/json-serializer.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/serializers/json-serializer.ts index 6305eaf4680..9128410688f 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/serializers/json-serializer.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/serializers/json-serializer.ts @@ -103,8 +103,22 @@ function serializeNode( // Add optional properties if (config.credentials) { - // Serialize credentials to ensure newCredential() markers are converted to JSON - n8nNode.credentials = deepCopy(config.credentials); + if (typeof config.credentials !== 'object') { + // Real workflows occasionally carry credentials as a primitive (e.g. the + // post-redaction string `"[REDACTED]"`). Pass through unchanged. + n8nNode.credentials = deepCopy(config.credentials); + } else { + // `NodeConfig.credentials` is typed wide (also accepts PlaceholderValue) + // at the public API. By this point `normalizeNodeConfig` has rewritten any + // placeholder() markers to newCredential() markers, so no __placeholder + // values remain at runtime. Narrow the value type for the serializer. + const resolvable: NonNullable = {}; + for (const [key, value] of Object.entries(config.credentials)) { + if (value && typeof value === 'object' && '__placeholder' in value) continue; + resolvable[key] = value; + } + n8nNode.credentials = deepCopy(resolvable); + } } if (config.disabled) { n8nNode.disabled = config.disabled;