diff --git a/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-request.dto.test.ts b/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-request.dto.test.ts index 30707adf360..5063254ab95 100644 --- a/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-request.dto.test.ts @@ -8,6 +8,7 @@ describe('ImportPackageRequestDto', () => { expect(result.data).toEqual({ credentialMatchingMode: 'id-only', credentialMissingMode: 'must-preexist', + credentialBindings: {}, workflowConflictPolicy: 'fail', workflowPublishingPolicy: 'preserve-published-state', workflowIdPolicy: 'new', @@ -26,6 +27,7 @@ describe('ImportPackageRequestDto', () => { expect(result.data).toEqual({ credentialMatchingMode: 'id-only', credentialMissingMode: 'must-preexist', + credentialBindings: {}, workflowConflictPolicy: 'fail', workflowPublishingPolicy: 'preserve-published-state', workflowIdPolicy: 'new', @@ -46,6 +48,7 @@ describe('ImportPackageRequestDto', () => { folderId: 'fld-1', credentialMatchingMode: 'id-only', credentialMissingMode: 'must-preexist', + credentialBindings: {}, workflowConflictPolicy: 'new-version', workflowPublishingPolicy: 'preserve-published-state', workflowIdPolicy: 'new', @@ -65,6 +68,7 @@ describe('ImportPackageRequestDto', () => { projectId: 'proj-1', credentialMatchingMode: 'id-only', credentialMissingMode: 'must-preexist', + credentialBindings: {}, workflowConflictPolicy: 'skip', workflowPublishingPolicy: 'preserve-published-state', workflowIdPolicy: 'new', @@ -90,6 +94,33 @@ describe('ImportPackageRequestDto', () => { ).toBe(false); }); + it('parses credentialBindings from a JSON object string', () => { + const result = ImportPackageRequestDto.safeParse({ + credentialBindings: '{"source-cred":"target-cred"}', + workflowConflictPolicy: 'fail', + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.credentialBindings).toEqual({ 'source-cred': 'target-cred' }); + } + }); + + it.each([ + { name: 'invalid JSON', credentialBindings: 'not json' }, + { name: 'array JSON', credentialBindings: '[]' }, + { name: 'non-string target id', credentialBindings: '{"source":1}' }, + { name: 'empty source id', credentialBindings: '{"":"target"}' }, + { name: 'empty target id', credentialBindings: '{"source":""}' }, + ])('rejects credentialBindings with $name', ({ credentialBindings }) => { + expect( + ImportPackageRequestDto.safeParse({ + credentialBindings, + workflowConflictPolicy: 'fail', + }).success, + ).toBe(false); + }); + it('rejects omitted workflowConflictPolicy', () => { expect(ImportPackageRequestDto.safeParse({}).success).toBe(false); }); diff --git a/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts b/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts index 6ac08635f8d..2e7f77acc23 100644 --- a/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts @@ -8,6 +8,7 @@ export const IMPORT_PACKAGE_REQUEST_FORM_FIELDS = [ 'folderId', 'credentialMatchingMode', 'credentialMissingMode', + 'credentialBindings', 'workflowConflictPolicy', 'workflowPublishingPolicy', 'workflowIdPolicy', @@ -23,11 +24,50 @@ const optionalFormId = z return trimmed.length > 0 ? trimmed : undefined; }); +function isStringRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + return Object.entries(value).every( + ([sourceId, targetId]) => + sourceId.length > 0 && typeof targetId === 'string' && targetId.length > 0, + ); +} + +const credentialBindingsSchema = z + .string() + .optional() + .transform((value, ctx) => { + if (value === undefined || value.trim().length === 0) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'credentialBindings must be a JSON object mapping source credential ids to target credential ids', + }); + return z.NEVER; + } + + if (!isStringRecord(parsed)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'credentialBindings must be a JSON object mapping source credential ids to target credential ids', + }); + return z.NEVER; + } + + return parsed; + }); + export class ImportPackageRequestDto extends Z.class({ projectId: optionalFormId, folderId: optionalFormId, credentialMatchingMode: z.enum(['id-only']).optional().default('id-only'), credentialMissingMode: z.enum(['must-preexist']).optional().default('must-preexist'), + credentialBindings: credentialBindingsSchema, workflowConflictPolicy: z.enum(['new-version', 'fail', 'skip']), workflowPublishingPolicy: z .enum(['preserve-published-state', 'match-source', 'publish-all', 'unpublish-all']) diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-pipeline.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-pipeline.integration.test.ts index 8271c5bdf94..56a23f0b248 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-pipeline.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-pipeline.integration.test.ts @@ -39,6 +39,7 @@ import { FORMAT_VERSION } from '../spec/constants'; import { buildImportPackageBuffer, githubCredentialPayload, + PACKAGE_GITHUB_CREDENTIAL_TYPE, serializedWorkflow, serializedWorkflowWithCredential, } from './fixtures/package-fixtures'; @@ -49,6 +50,7 @@ type ImportPackageParams = Omit< ImportPackageRequest, | 'credentialMatchingMode' | 'credentialMissingMode' + | 'credentialBindings' | 'workflowConflictPolicy' | 'workflowPublishingPolicy' | 'workflowIdPolicy' @@ -58,6 +60,7 @@ type ImportPackageParams = Omit< ImportPackageRequest, | 'credentialMatchingMode' | 'credentialMissingMode' + | 'credentialBindings' | 'workflowConflictPolicy' | 'workflowPublishingPolicy' | 'workflowIdPolicy' @@ -1157,6 +1160,46 @@ describe('ImportPipeline credential resolution', () => { expect(await Container.get(WorkflowRepository).count()).toBe(2); }); + it('should succeed when importing workflows with explicit credential bindings', async () => { + const owner = await createOwner(); + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + const targetCredential = await saveOwnedCredential( + githubCredentialPayload({ name: 'Target GitHub' }), + { + project: personalProject, + }, + ); + + const result = await importPackage({ + user: owner, + credentialBindings: new Map([['source-credential', targetCredential.id]]), + packageBuffer: await buildImportPackageBuffer( + [ + serializedWorkflowWithCredential({ + id: 'wf-bound-cred', + name: 'With bound cred', + credentialId: 'source-credential', + credentialName: 'Source GitHub', + }), + ], + { sourceId }, + ), + }); + + expect(result.bindings.credentials).toEqual({ + 'source-credential': targetCredential.id, + }); + + const workflow = await Container.get(WorkflowRepository).findOneOrFail({ + where: { name: 'With bound cred' }, + }); + expect(workflow.nodes[0].credentials?.[PACKAGE_GITHUB_CREDENTIAL_TYPE]?.id).toBe( + targetCredential.id, + ); + }); + it('reports mixed unknown_type and not_found failures in one response', async () => { const owner = await createOwner(); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts index 589f4822e94..79cfa7a6d1d 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts @@ -28,7 +28,7 @@ describe('createN8nPackageMulterOptions', () => { fileSize: 8 * 1024 * 1024, files: 1, parts: IMPORT_PACKAGE_REQUEST_FORM_FIELDS.length + 2, - fieldSize: 128, + fieldSize: 64 * 1024, }); }); @@ -99,11 +99,16 @@ describe('resolveImportPackageUpload', () => { expect(file.buffer).toBe(packageBuffer); }); - it('accepts routing and workflow update policy fields in the body', () => { + it('accepts routing, credential binding, and workflow policy fields in the body', () => { expect(() => resolveImportPackageUpload({ files: [makeFile('package', packageBuffer)], - body: { folderId: 'fld-1', workflowConflictPolicy: 'skip', package: '' }, + body: { + folderId: 'fld-1', + credentialBindings: '{"source":"target"}', + workflowConflictPolicy: 'skip', + package: '', + }, }), ).not.toThrow(); }); diff --git a/packages/cli/src/modules/n8n-packages/engine/import-pipeline.ts b/packages/cli/src/modules/n8n-packages/engine/import-pipeline.ts index 2fd7ce4ed4b..daf24374274 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-pipeline.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-pipeline.ts @@ -75,6 +75,7 @@ export class ImportPipeline { requirements: manifest.requirements?.credentials, matchingMode: request.credentialMatchingMode, missingMode: request.credentialMissingMode, + credentialBindings: request.credentialBindings, targetProject: project, user: request.user, }; @@ -139,10 +140,11 @@ export class ImportPipeline { const credentialFailures: BlockingIssue[] = this.credentialImporter .blockingFailures(credentialResolution, credentialRequest) - .map(({ kind, sourceId, usedByWorkflows }) => ({ + .map(({ kind, sourceId, targetId, usedByWorkflows }) => ({ type: 'credential-unresolved', kind, sourceId, + ...(targetId ? { targetId } : {}), usedByWorkflows, })); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts index 2d11615ecf0..e65476588ec 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts @@ -29,10 +29,12 @@ describe('CredentialImporter', () => { const bindingRequest = ( requirements: CredentialBindingRequest['requirements'], + credentialBindings?: CredentialBindingRequest['credentialBindings'], ): CredentialBindingRequest => ({ requirements, matchingMode: 'id-only', missingMode: 'must-preexist', + credentialBindings, targetProject, user, }); @@ -91,4 +93,74 @@ describe('CredentialImporter', () => { { kind: 'not_found', sourceId: 'cred-missing', usedByWorkflows: ['wf-1'] }, ]); }); + + it('uses explicit bindings before id-only matching', async () => { + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + usable('target-cred'), + ]); + + const request = bindingRequest( + [ + { + id: 'source-cred', + name: 'Source GitHub', + type: 'githubApi', + usedByWorkflows: ['wf-1'], + }, + ], + new Map([['source-cred', 'target-cred']]), + ); + const credentialResolution = await importer.plan(request); + + expect(credentialResolution.successes).toEqual(new Map([['source-cred', 'target-cred']])); + expect(credentialResolution.failures).toEqual([]); + }); + + it('should error when the source of an explicit credential binding is not in the package', async () => { + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + usable('target-cred'), + ]); + + const request = bindingRequest([], new Map([['missing-source', 'target-cred']])); + const credentialResolution = await importer.plan(request); + + expect(credentialResolution.successes).toEqual(new Map()); + expect(credentialResolution.failures).toEqual([ + { + kind: 'source_not_found', + sourceId: 'missing-source', + targetId: 'target-cred', + usedByWorkflows: [], + }, + ]); + }); + + it('should error when the target of an explicit credential binding is inaccessible', async () => { + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + usable('source-cred'), + ]); + + const request = bindingRequest( + [ + { + id: 'source-cred', + name: 'Source GitHub', + type: 'githubApi', + usedByWorkflows: ['wf-1'], + }, + ], + new Map([['source-cred', 'target-missing']]), + ); + const credentialResolution = await importer.plan(request); + + expect(credentialResolution.successes).toEqual(new Map()); + expect(credentialResolution.failures).toEqual([ + { + kind: 'not_found', + sourceId: 'source-cred', + targetId: 'target-missing', + usedByWorkflows: ['wf-1'], + }, + ]); + }); }); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts index 78fd06d1ea9..dc08c9b0bb1 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts @@ -185,6 +185,50 @@ describe('IdBasedCredentialMatcher', () => { expect(result.successes).toEqual(new Map([['cred-manifest', 'cred-manifest']])); expect(result.failures).toEqual([]); }); + + it('should resolve explicit credential bindings when the target credential is accessible', async () => { + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + usable('target-cred'), + ]); + + const requirement = { + id: 'source-cred', + name: 'Source GitHub', + type: 'githubApi', + usedByWorkflows: ['wf-1'], + }; + + const result = await matcherFactory.getMatcher('id-only').match([requirement], { + ...context, + credentialBindings: new Map([['source-cred', 'target-cred']]), + }); + + expect(result.successes).toEqual(new Map([['source-cred', 'target-cred']])); + expect(result.failures).toEqual([]); + }); + + it('should error when the target of an explicit credential binding is inaccessible', async () => { + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + usable('source-cred'), + ]); + + const requirement = { + id: 'source-cred', + name: 'Source GitHub', + type: 'githubApi', + usedByWorkflows: ['wf-1'], + }; + + const result = await matcherFactory.getMatcher('id-only').match([requirement], { + ...context, + credentialBindings: new Map([['source-cred', 'target-missing']]), + }); + + expect(result.successes).toEqual(new Map()); + expect(result.failures).toEqual([ + { ...createFailure(requirement, 'not_found'), targetId: 'target-missing' }, + ]); + }); }); describe('CredentialMatcherFactory', () => { diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential-importer.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential-importer.ts index 4875c0584f7..9c1a2740b1d 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential-importer.ts @@ -23,6 +23,7 @@ export class CredentialImporter { .match(request.requirements, { targetProject: request.targetProject, user: request.user, + credentialBindings: request.credentialBindings, }); } diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher.ts index a8cb2e44660..341203ad217 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher.ts @@ -14,6 +14,7 @@ import type { PackageCredentialRequirement } from '../../spec/requirements.schem export interface CredentialMatcherContext { targetProject: Project; user: User; + credentialBindings?: ImportBindingMap; } export abstract class CredentialMatcher { @@ -27,15 +28,21 @@ export abstract class CredentialMatcher { requirements: PackageCredentialRequirement[] | undefined, context: CredentialMatcherContext, ): Promise { + const orphanFailures = orphanBindingFailures(context.credentialBindings, requirements); const { known, unknownTypeFailures } = partitionByKnownType(requirements, this.credentialTypes); const successes = await this.resolve(known, context); const notFoundFailures = known .filter((reference) => !successes.has(reference.id)) - .map((reference) => createFailure(reference, 'not_found')); + .map((reference) => + createNotFoundFailure(reference, context.credentialBindings?.get(reference.id)), + ); - return { successes, failures: [...unknownTypeFailures, ...notFoundFailures] }; + return { + successes, + failures: [...orphanFailures, ...unknownTypeFailures, ...notFoundFailures], + }; } protected abstract resolve( @@ -64,3 +71,43 @@ function partitionByKnownType( return { known, unknownTypeFailures }; } + +/** + * When the importer supplied an explicit binding, include the requested target id on + * the failure so callers can report which credential was unreachable. + */ +function createNotFoundFailure( + reference: PackageCredentialRequirement, + requestedTargetId: string | undefined, +): CredentialResolutionFailure { + const failure = createFailure(reference, 'not_found'); + return requestedTargetId === undefined ? failure : { ...failure, targetId: requestedTargetId }; +} + +/** + * Rejects explicit bindings whose source id is not declared in the package. + * These cannot go through normal requirement matching because there is no + * package credential entry to resolve against. + */ +function orphanBindingFailures( + bindings: ImportBindingMap | undefined, + requirements: PackageCredentialRequirement[] | undefined, +): CredentialResolutionFailure[] { + if (!bindings || bindings.size === 0) return []; + + const requirementIds = new Set((requirements ?? []).map((requirement) => requirement.id)); + const failures: CredentialResolutionFailure[] = []; + + for (const [sourceId, targetId] of bindings) { + if (!requirementIds.has(sourceId)) { + failures.push({ + kind: 'source_not_found', + sourceId, + targetId, + usedByWorkflows: [], + }); + } + } + + return failures; +} diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts index c3459bacfe5..16f9c4a8582 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts @@ -14,11 +14,12 @@ export interface WorkflowCredentialRequirement { credentialType: string; } -export type CredentialResolutionFailureKind = 'not_found' | 'unknown_type'; +export type CredentialResolutionFailureKind = 'not_found' | 'unknown_type' | 'source_not_found'; export type CredentialResolutionFailure = { kind: CredentialResolutionFailureKind; sourceId: string; + targetId?: string; usedByWorkflows: string[]; }; @@ -31,6 +32,7 @@ export interface CredentialBindingRequest { requirements: PackageCredentialRequirement[] | undefined; matchingMode: CredentialMatchingMode; missingMode: CredentialMissingMode; + credentialBindings?: ImportBindingMap; targetProject: Project; user: User; } diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/id-based-credential-matcher.ts b/packages/cli/src/modules/n8n-packages/entities/credential/id-based-credential-matcher.ts index 6eb20b5daec..dc8dbb90f89 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/id-based-credential-matcher.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/id-based-credential-matcher.ts @@ -25,26 +25,29 @@ export class IdBasedCredentialMatcher extends CredentialMatcher { known: PackageCredentialRequirement[], context: CredentialMatcherContext, ): Promise { + const bindings = context.credentialBindings; + const targetIds = known.map((reference) => bindings?.get(reference.id) ?? reference.id); const resolvableIds = await this.findResolvableCredentialIds( - known.map((reference) => reference.id), + targetIds, context.targetProject, context.user, ); return new Map( - known - .filter((reference) => resolvableIds.has(reference.id)) - // id-only matching: the target credential id is the source id. - .map((reference) => [reference.id, reference.id]), + known.flatMap((reference) => { + const targetId = bindings?.get(reference.id) ?? reference.id; + if (!resolvableIds.has(targetId)) return []; + return [[reference.id, targetId] as const]; + }), ); } private async findResolvableCredentialIds( - sourceIds: string[], + candidateIds: string[], targetProject: Project, user: User, ): Promise> { - const uniqueIds = new Set(sourceIds); + const uniqueIds = new Set(candidateIds); if (uniqueIds.size === 0) { return new Set(); } diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-importer.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-importer.ts index 88300e0f76c..5091e398fe8 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-importer.ts @@ -1,4 +1,4 @@ -import type { WorkflowEntity } from '@n8n/db'; +import { WorkflowEntity } from '@n8n/db'; import { Service } from '@n8n/di'; import { WorkflowCreationService } from '@/workflows/workflow-creation.service'; @@ -125,7 +125,7 @@ export class WorkflowImporter { const outcomes: WorkflowImportOutcome[] = []; for (const item of plan.items) { - const outcome = await this.applyItem(item, context); + const outcome = await this.applyItem(item, context, bindings); outcomes.push(outcome); // Works for every status: created/updated/skipped all resolve to a real target id. workflowBindings.set(outcome.sourceWorkflowId, outcome.workflow.id); @@ -137,6 +137,7 @@ export class WorkflowImporter { private async applyItem( item: WorkflowPlanItem, context: WorkflowImportContext, + bindings: PackageImportBindings, ): Promise { if (item.action === 'skip') { return { @@ -146,7 +147,7 @@ export class WorkflowImporter { }; } - const savedWorkflow = await this.persistWorkflow(context, item); + const savedWorkflow = await this.persistWorkflow(context, item, bindings.credentials); const workflow = await this.workflowPublisher.apply( context.user, item, @@ -164,10 +165,11 @@ export class WorkflowImporter { private async persistWorkflow( context: WorkflowImportContext, item: PersistedWorkflowPlanItem, + credentialBindings: PackageImportBindings['credentials'], ): Promise { if (item.action === 'create') { - item.entity.id = item.decidedId; - return await this.workflowCreationService.createWorkflow(context.user, item.entity, { + const entity = prepareEntityForPersist(item.entity, credentialBindings, item.decidedId); + return await this.workflowCreationService.createWorkflow(context.user, entity, { projectId: context.projectId, parentFolderId: context.folderId ?? undefined, publicApi: true, @@ -176,18 +178,48 @@ export class WorkflowImporter { }); } - const workflow = await this.workflowService.update( - context.user, - item.entity, - item.existing.id, - { publicApi: true, source: 'import' }, - ); + const entity = prepareEntityForPersist(item.entity, credentialBindings); + const workflow = await this.workflowService.update(context.user, entity, item.existing.id, { + publicApi: true, + source: 'import', + }); // update() doesn't re-hydrate parentFolder; carry over the existing folder for the result. workflow.parentFolder = item.existing.parentFolder; return workflow; } } +/** Clones package content for persistence without mutating the import plan. */ +function prepareEntityForPersist( + source: WorkflowEntity, + credentialBindings: PackageImportBindings['credentials'], + decidedId?: string, +): WorkflowEntity { + const entity = Object.assign(new WorkflowEntity(), source, { + nodes: structuredClone(source.nodes), + ...(decidedId !== undefined ? { id: decidedId } : {}), + }); + applyCredentialBindingsInPlace(entity, credentialBindings); + return entity; +} + +/** Mutates node credential ids on `entity` using the resolved import binding map. */ +function applyCredentialBindingsInPlace( + entity: WorkflowEntity, + credentialBindings: PackageImportBindings['credentials'], +): void { + for (const node of entity.nodes) { + for (const details of Object.values(node.credentials ?? {})) { + if (!details.id) continue; + + const targetId = credentialBindings.get(details.id); + if (targetId) { + details.id = targetId; + } + } + } +} + function toPlanItem( prepared: PreparedWorkflow, existing: WorkflowEntity | null, diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts index 80916ee8625..b69766a61e2 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -47,6 +47,7 @@ export type ImportPackageRequest = { export type ImportCredentialProperties = { credentialMatchingMode: CredentialMatchingMode; credentialMissingMode: CredentialMissingMode; + credentialBindings?: ImportBindingMap; }; export type ImportWorkflowProperties = { @@ -87,8 +88,9 @@ export type BlockingIssue = } | { type: 'credential-unresolved'; - kind: 'not_found' | 'unknown_type'; + kind: 'not_found' | 'unknown_type' | 'source_not_found'; sourceId: string; + targetId?: string; usedByWorkflows: string[]; }; diff --git a/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts b/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts index 4f04b832957..5e3da724360 100644 --- a/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts +++ b/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts @@ -14,8 +14,8 @@ const IMPORT_PACKAGE_BODY_FIELD_SET = new Set([ 'package', ]); -/** Max length for optional routing ids in multipart form fields. */ -const IMPORT_PACKAGE_FIELD_SIZE_BYTES = 128; +/** Max length for multipart text fields, including JSON credential bindings. */ +const IMPORT_PACKAGE_FIELD_SIZE_BYTES = 64 * 1024; /** * `package` file + every documented form field, plus one because busboy rejects diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts index 78d4f805637..50a9aeb560e 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts @@ -79,6 +79,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = { folderId: payload.data.folderId, credentialMatchingMode: payload.data.credentialMatchingMode, credentialMissingMode: payload.data.credentialMissingMode, + credentialBindings: new Map(Object.entries(payload.data.credentialBindings)), workflowConflictPolicy: payload.data.workflowConflictPolicy, workflowPublishingPolicy: payload.data.workflowPublishingPolicy, workflowIdPolicy: payload.data.workflowIdPolicy, diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml index 028b021db9f..2c88aec9e23 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml @@ -11,8 +11,8 @@ post: Imports a gzip-compressed tar package (`.n8np`) into the target project. Send the archive as the multipart field `package`. Optional routing uses form fields `projectId` and `folderId` (omit or send empty for defaults). The optional - `credentialMatchingMode`, `credentialMissingMode`, `workflowIdPolicy`, and - `workflowPublishingPolicy` fields take their defaults when omitted. The required + `credentialMatchingMode`, `credentialMissingMode`, `credentialBindings`, + `workflowIdPolicy`, and `workflowPublishingPolicy` fields take their defaults when omitted. The required `workflowConflictPolicy` field controls what happens when a package workflow matches an existing workflow by source id in the target project. Maximum upload size is `N8N_ENDPOINTS_PAYLOAD_SIZE_MAX` MB (default 16). @@ -67,6 +67,14 @@ post: What to do when a credential reference cannot be resolved. `must-preexist` requires every referenced credential to already exist and be accessible on the target instance. + credentialBindings: + type: string + default: '{}' + description: > + Optional JSON object mapping credential ids from the package + manifest to credential ids on the target instance. These explicit + bindings are validated and applied before `credentialMatchingMode` + resolution runs. workflowConflictPolicy: type: string enum: diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importBlockingIssue.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importBlockingIssue.yml index 8a7393c7aee..5e2f1261c25 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importBlockingIssue.yml +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importBlockingIssue.yml @@ -70,8 +70,12 @@ oneOf: enum: - not_found - unknown_type + - source_not_found sourceId: type: string + targetId: + type: string + description: Target credential id for an explicit credential binding. usedByWorkflows: type: array items: diff --git a/packages/cli/test/integration/public-api/n8n-packages-import.test.ts b/packages/cli/test/integration/public-api/n8n-packages-import.test.ts index 21f1bb6ea86..f8ddcdb6ede 100644 --- a/packages/cli/test/integration/public-api/n8n-packages-import.test.ts +++ b/packages/cli/test/integration/public-api/n8n-packages-import.test.ts @@ -166,6 +166,7 @@ describe('POST /n8n-packages/import', () => { .field('folderId', '') .field('credentialMatchingMode', 'id-only') .field('credentialMissingMode', 'must-preexist') + .field('credentialBindings', '{}') .field('workflowConflictPolicy', 'fail') .field('workflowIdPolicy', 'new') .attach('package', tarBuffer, 'import.n8np');