feat(core): Import a project with folders and workflows (no-changelog) (#34026)

This commit is contained in:
James Gee 2026-07-17 10:53:06 +02:00 committed by GitHub
parent f7f0556f65
commit 4844223dbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 996 additions and 153 deletions

View File

@ -63,7 +63,7 @@ describe('LogStreamingEventRelay', () => {
lastName: 'User',
role: { slug: 'global:admin' },
},
projectId: 'proj-brie',
projectIds: ['proj-brie', 'proj-stilton'],
folderId: 'folder-cheese',
workflowIds: ['wf-cheddar', 'wf-brie'],
options: {
@ -113,7 +113,7 @@ describe('LogStreamingEventRelay', () => {
_firstName: 'Import',
_lastName: 'User',
globalRole: 'global:admin',
projectId: 'proj-brie',
projectIds: ['proj-brie', 'proj-stilton'],
folderId: 'folder-cheese',
workflowIds: ['wf-cheddar', 'wf-brie'],
options: {

View File

@ -2223,7 +2223,7 @@ describe('TelemetryEventRelay', () => {
it('should track on `n8n-package-imported` event with params and counts', () => {
const event: RelayEventMap['n8n-package-imported'] = {
user: { id: 'user123' },
projectId: 'project123',
projectIds: ['project123'],
folderId: 'folder123',
workflowIds: ['wf1', 'wf2', 'wf3'],
options: {

View File

@ -94,7 +94,7 @@ export type RelayEventMap = {
'n8n-package-imported': {
user: UserLike;
projectId: string;
projectIds: string[];
folderId: string | null;
workflowIds: string[];
options: ImportPackageEventOptions;

View File

@ -33,9 +33,9 @@ flowchart LR
workflows + their folder shells + credential deps into a target project) → `WorkflowPackageImporter`.
- `WorkflowPackageImporter` resolves the target scope from the request, then delegates the plan/gate/apply
work to `ImportOrchestrator` (brings folders + workflows + credential deps into one project scope).
`ProjectPackageImporter` creates the project shells; reusing `ImportOrchestrator` for a project's own
contents is a follow-up. Don't split folder vs workflow: they share target resolution, credential
resolution, and publishing.
`ProjectPackageImporter` creates the project shells, then reuses `ImportOrchestrator` per project to
bring each one's own folders + workflows + credential deps into scope. Don't split folder vs workflow:
they share target resolution, credential resolution, and publishing.
### Adding an IMPORT property

View File

@ -505,11 +505,48 @@ describe('folder shell import', () => {
},
});
// The folder-nested workflow is now imported (LIGO-723), so its missing must-preexist credential
// resolves through the same gate as a top-level workflow and blocks the import before any writes.
await expect(
importFolders({ user: owner, projectId: project.id, packageBuffer }),
).rejects.toBeInstanceOf(UnprocessableRequestError);
expect(await findFolder('F1')).toBeNull();
});
it('blocks re-importing a matched workflow into a different folder and never re-parents it', async () => {
const first = await importFolders({
user: owner,
projectId: project.id,
packageBuffer: await buildEntityPackageBuffer({
folders: [{ target: 'folders/fa', folder: serializedFolder({ id: 'FA', name: 'fa' }) }],
workflows: [
{
target: 'folders/fa/workflows/wf',
workflow: serializedWorkflow({ id: 'WF', name: 'wf' }),
},
],
}),
});
const localId = first.workflows[0].localId;
expect((await findWorkflow(localId))?.parentFolder?.id).toBe('FA');
// Re-import the same source workflow nested under a different folder: the matched workflow is not
// re-parented; the mismatch blocks the whole import before anything is written.
await expect(
importFolders({
user: owner,
projectId: project.id,
packageBuffer: await buildEntityPackageBuffer({
folders: [{ target: 'folders/fb', folder: serializedFolder({ id: 'FB', name: 'fb' }) }],
workflows: [
{
target: 'folders/fb/workflows/wf',
workflow: serializedWorkflow({ id: 'WF', name: 'wf' }),
},
],
}),
}),
).rejects.toBeInstanceOf(ConflictError);
expect((await findWorkflow(localId))?.parentFolder?.id).toBe('FA');
expect(await findFolder('FB')).toBeNull();
});
});

View File

@ -1,10 +1,19 @@
import { LicenseState } from '@n8n/backend-common';
import { testDb, testModules } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { FolderRepository, ProjectRelationRepository, ProjectRepository } from '@n8n/db';
import {
FolderRepository,
ProjectRelationRepository,
ProjectRepository,
SharedWorkflowRepository,
WorkflowRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { createOwner } from '@test-integration/db/users';
import { LicenseMocker } from '@test-integration/license';
@ -12,12 +21,19 @@ import { N8nPackagesService } from '../n8n-packages.service';
import type { ImportPackageRequest } from '../n8n-packages.types';
import {
buildEntityPackageBuffer,
credentialRequirementsFromWorkflows,
serializedFolder,
serializedProject,
serializedWorkflow,
serializedWorkflowWithCredential,
} from './fixtures/package-fixtures';
async function importProjects(user: User, packageBuffer: Buffer, apiKeyScopes?: string[]) {
async function importProjects(
user: User,
packageBuffer: Buffer,
apiKeyScopes?: string[],
overrides?: Partial<ImportPackageRequest>,
) {
const request: ImportPackageRequest = {
user,
packageBuffer,
@ -31,6 +47,7 @@ async function importProjects(user: User, packageBuffer: Buffer, apiKeyScopes?:
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
...overrides,
};
return await Container.get(N8nPackagesService).importPackage(request);
}
@ -41,6 +58,20 @@ async function findProject(id: string) {
return await Container.get(ProjectRepository).findOne({ where: { id } });
}
async function findFolder(id: string) {
return await Container.get(FolderRepository).findOne({
where: { id },
relations: { homeProject: true },
});
}
async function findWorkflow(id: string) {
return await Container.get(WorkflowRepository).findOne({
where: { id },
relations: { parentFolder: true },
});
}
async function isAdminOf(projectId: string, userId: string): Promise<boolean> {
const count = await Container.get(ProjectRelationRepository).count({
where: { projectId, userId, role: { slug: 'project:admin' } },
@ -53,7 +84,7 @@ beforeAll(async () => {
await testDb.init();
licenseMocker.mockLicenseState(Container.get(LicenseState));
licenseMocker.setDefaults({
features: ['feat:projectRole:admin'],
features: ['feat:projectRole:admin', 'feat:folders'],
quotas: { 'quota:maxTeamProjects': 100 },
});
});
@ -156,6 +187,23 @@ describe('project shell import', () => {
expect((await findProject('P1'))?.name).toBe('brie renamed');
});
it('rejects a project package whose manifest project id disagrees with its project.json', async () => {
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/p', project: serializedProject({ id: 'real-id', name: 'p' }) },
],
// Manifest points the same target at a different id than project.json declares. The project is
// created under project.json's id, but its contents scope by manifest id — so they must agree.
manifestExtras: { projects: [{ id: 'manifest-id', name: 'p', target: 'projects/p' }] },
});
await expect(importProjects(owner, packageBuffer)).rejects.toThrow(
/declares id "real-id" but the manifest lists it as "manifest-id"/,
);
// Rejected while parsing, before any project shell is created.
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(0);
});
it('rejects a project package when the API key lacks the project:create scope', async () => {
const packageBuffer = await buildEntityPackageBuffer({
projects: [
@ -181,30 +229,275 @@ describe('project shell import', () => {
await expect(importProjects(owner, packageBuffer)).rejects.toBeInstanceOf(ForbiddenError);
});
it('ignores folders and workflows nested inside the project (deferred to a follow-up)', async () => {
it('recreates the project folder tree and places nested workflows into the project scope', async () => {
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
{
target: 'projects/team-ligo',
project: serializedProject({ id: 'P1', name: 'team-ligo' }),
},
],
folders: [
// An empty folder shell alongside a populated, nested hierarchy.
{
target: 'projects/brie/folders/in_progress',
folder: serializedFolder({ id: 'NestedFolder', name: 'in_progress' }),
target: 'projects/team-ligo/folders/to_production',
folder: serializedFolder({ id: 'TP', name: 'to_production' }),
},
{
target: 'projects/team-ligo/folders/in_progress',
folder: serializedFolder({ id: 'IP', name: 'in_progress' }),
},
{
target: 'projects/team-ligo/folders/in_progress/nested',
folder: serializedFolder({ id: 'NE', name: 'nested', parentFolderId: 'IP' }),
},
],
workflows: [
{
target: 'projects/brie/folders/in_progress/workflows/triage',
workflow: serializedWorkflow({ id: 'NestedWf', name: 'triage' }),
target: 'projects/team-ligo/folders/in_progress/workflows/triage',
workflow: serializedWorkflow({ id: 'WF1', name: 'triage' }),
},
{
target: 'projects/team-ligo/folders/in_progress/nested/workflows/playground',
workflow: serializedWorkflow({ id: 'WF2', name: 'playground' }),
},
],
});
const result = await importProjects(owner, packageBuffer);
expect(result.projects.map((p) => p.localId)).toEqual(['P1']);
expect(result.folders).toEqual([]);
expect(result.workflows).toEqual([]);
expect(await Container.get(FolderRepository).findOneBy({ id: 'NestedFolder' })).toBeNull();
expect(result.projects).toEqual([
{ sourceProjectId: 'P1', localId: 'P1', name: 'team-ligo', status: 'created' },
]);
// Every folder (incl. the empty shell) lands in the project; the nested one keeps its parent.
for (const id of ['TP', 'IP', 'NE']) {
expect((await findFolder(id))?.homeProject.id).toBe('P1');
}
expect((await findFolder('NE'))?.parentFolderId).toBe('IP');
// Each workflow is placed under the folder it belongs to, scoped to the project.
const triage = result.workflows.find((w) => w.sourceWorkflowId === 'WF1')!;
const playground = result.workflows.find((w) => w.sourceWorkflowId === 'WF2')!;
expect(triage.projectId).toBe('P1');
expect((await findWorkflow(triage.localId))?.parentFolder?.id).toBe('IP');
expect((await findWorkflow(playground.localId))?.parentFolder?.id).toBe('NE');
});
it('populates each project in a multi-project package into its own scope', async () => {
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
{ target: 'projects/stilton', project: serializedProject({ id: 'P2', name: 'stilton' }) },
],
folders: [
{ target: 'projects/brie/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) },
{ target: 'projects/stilton/folders/b', folder: serializedFolder({ id: 'FB', name: 'b' }) },
],
workflows: [
{
target: 'projects/brie/folders/a/workflows/wfa',
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
},
{
target: 'projects/stilton/folders/b/workflows/wfb',
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }),
},
],
});
const result = await importProjects(owner, packageBuffer);
expect(result.projects.map((p) => p.localId).sort()).toEqual(['P1', 'P2']);
expect((await findFolder('FA'))?.homeProject.id).toBe('P1');
expect((await findFolder('FB'))?.homeProject.id).toBe('P2');
const wfa = result.workflows.find((w) => w.sourceWorkflowId === 'WFA')!;
const wfb = result.workflows.find((w) => w.sourceWorkflowId === 'WFB')!;
expect(wfa.projectId).toBe('P1');
expect(wfb.projectId).toBe('P2');
expect((await findWorkflow(wfa.localId))?.parentFolder?.id).toBe('FA');
expect((await findWorkflow(wfb.localId))?.parentFolder?.id).toBe('FB');
});
it('reuses the project and folder and updates the workflow on re-import', async () => {
const pkg = async () =>
await buildEntityPackageBuffer({
projects: [
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
],
folders: [
{ target: 'projects/brie/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) },
],
workflows: [
{
target: 'projects/brie/folders/a/workflows/wf',
workflow: serializedWorkflow({ id: 'WF', name: 'wf' }),
},
],
});
const first = await importProjects(owner, await pkg());
const localId = first.workflows[0].localId;
const second = await importProjects(owner, await pkg());
expect(second.workflows[0]).toMatchObject({ status: 'updated', localId, parentFolderId: 'FA' });
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(1);
expect(await Container.get(FolderRepository).countBy({ id: 'FA' })).toBe(1);
expect(await Container.get(WorkflowRepository).countBy({ id: localId })).toBe(1);
});
it('resolves a project workflow credential in the project scope, blocking when it is missing', async () => {
const workflow = serializedWorkflowWithCredential({
id: 'WF',
name: 'triage',
credentialId: 'missing-cred',
credentialName: 'Linear',
});
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
],
folders: [
{ target: 'projects/brie/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) },
],
workflows: [{ target: 'projects/brie/folders/a/workflows/triage', workflow }],
manifestExtras: {
requirements: { credentials: credentialRequirementsFromWorkflows([workflow]) },
},
});
// The project workflow's credential requirement resolves in the project scope; under must-preexist
// a missing credential blocks the import before anything is written — no folder, no project shell.
await expect(importProjects(owner, packageBuffer)).rejects.toBeInstanceOf(
UnprocessableRequestError,
);
expect(await findFolder('FA')).toBeNull();
expect(await findProject('P1')).toBeNull();
});
it('imports a project-root workflow (no enclosing folder) at the project root', async () => {
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
],
workflows: [
{
target: 'projects/brie/workflows/root-wf',
workflow: serializedWorkflow({ id: 'RWF', name: 'root-wf' }),
},
],
});
const result = await importProjects(owner, packageBuffer);
const summary = result.workflows.find((w) => w.sourceWorkflowId === 'RWF')!;
expect(summary).toMatchObject({ projectId: 'P1', parentFolderId: null });
// Persisted at the project root (no parent folder), owned by the imported project.
expect((await findWorkflow(summary.localId))?.parentFolder).toBeNull();
const shared = await Container.get(SharedWorkflowRepository).findOneBy({
workflowId: summary.localId,
});
expect(shared?.projectId).toBe('P1');
});
it('gates the whole package: a later project blocking leaves earlier projects unwritten', async () => {
const blocked = serializedWorkflowWithCredential({
id: 'WFB',
name: 'wfb',
credentialId: 'missing-cred',
credentialName: 'Linear',
});
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/alpha', project: serializedProject({ id: 'P1', name: 'alpha' }) },
{ target: 'projects/beta', project: serializedProject({ id: 'P2', name: 'beta' }) },
],
folders: [
{ target: 'projects/alpha/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) },
{ target: 'projects/beta/folders/b', folder: serializedFolder({ id: 'FB', name: 'b' }) },
],
workflows: [
{
target: 'projects/alpha/folders/a/workflows/wfa',
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
},
{ target: 'projects/beta/folders/b/workflows/wfb', workflow: blocked },
],
manifestExtras: {
requirements: { credentials: credentialRequirementsFromWorkflows([blocked]) },
},
});
// The second project's workflow needs a missing credential: every project is planned and
// validated before anything is written, so a block leaves nothing behind — not the first
// project's folder/workflow, nor either project shell.
await expect(importProjects(owner, packageBuffer)).rejects.toBeInstanceOf(
UnprocessableRequestError,
);
expect(await findFolder('FA')).toBeNull();
expect(await findFolder('FB')).toBeNull();
expect(await findProject('P1')).toBeNull();
expect(await findProject('P2')).toBeNull();
});
it('creates a new project under publish-all, planned before the project exists', async () => {
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
],
workflows: [
{
target: 'projects/brie/workflows/wf',
workflow: serializedWorkflow({ id: 'WF', name: 'wf' }),
},
],
});
// publish-all checks publish permission during planning, which now runs before the project
// is created. A project being created has no row to look up, so the import must not fail — its
// creator is admin and can always publish.
const result = await importProjects(owner, packageBuffer, undefined, {
workflowPublishingPolicy: 'publish-all',
});
expect(result.projects).toEqual([
{ sourceProjectId: 'P1', localId: 'P1', name: 'brie', status: 'created' },
]);
expect(result.workflows.find((w) => w.sourceWorkflowId === 'WF')?.projectId).toBe('P1');
expect(await findProject('P1')).not.toBeNull();
});
it('emits a single n8n-package-imported event aggregating every project in the package', async () => {
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
{ target: 'projects/stilton', project: serializedProject({ id: 'P2', name: 'stilton' }) },
],
workflows: [
{
target: 'projects/brie/workflows/wfa',
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
},
{
target: 'projects/stilton/workflows/wfb',
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }),
},
],
});
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
try {
await importProjects(owner, packageBuffer);
const importedEvents = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-imported');
expect(importedEvents).toHaveLength(1);
const payload = importedEvents[0][1] as RelayEventMap['n8n-package-imported'];
expect(payload.projectIds.sort()).toEqual(['P1', 'P2']);
expect(payload.workflowIds).toHaveLength(2);
expect(payload.folderId).toBeNull();
expect(payload.counts.workflows.created).toBe(2);
} finally {
emitSpy.mockRestore();
}
});
});

View File

@ -0,0 +1,63 @@
import type { PreparedWorkflow } from '../../entities/workflow/workflow-import.types';
import type { ImportBindingMap } from '../../n8n-packages.types';
import type { PackageCredentialRequirement } from '../../spec/requirements.schema';
import { identifyRequirements, scopeCredentialBindingsToRequirements } from '../import-result';
const requirement = (id: string, usedByWorkflows: string[]): PackageCredentialRequirement => ({
id,
name: id,
type: 'githubApi',
usedByWorkflows,
});
const prepared = (sourceWorkflowId: string): PreparedWorkflow =>
({ sourceWorkflowId }) as PreparedWorkflow;
describe('identifyRequirements', () => {
it('returns undefined when there are no requirements', () => {
expect(identifyRequirements(undefined, [prepared('W1')])).toBeUndefined();
});
it('keeps only in-scope workflows and drops requirements that no in-scope workflow uses', () => {
const requirements = [requirement('credA', ['W1', 'W2']), requirement('credB', ['W3'])];
const scoped = identifyRequirements(requirements, [prepared('W1')]);
// credA stays (W1 is in scope) but with W2 trimmed off; credB drops entirely (W3 is out of scope).
expect(scoped).toEqual([requirement('credA', ['W1'])]);
});
});
describe('scopeCredentialBindingsToRequirements', () => {
const bindings: ImportBindingMap = new Map([
['credA', 'target-a'],
['credB', 'target-b'],
]);
it('returns undefined when no bindings were supplied', () => {
expect(
scopeCredentialBindingsToRequirements(undefined, [requirement('credA', ['W1'])]),
).toBeUndefined();
});
it('keeps only bindings whose source id this scope requires', () => {
// Simulates a multi-project import where credB belongs to another project's workflows.
const scoped = scopeCredentialBindingsToRequirements(bindings, [requirement('credA', ['W1'])]);
expect(scoped).toEqual(new Map([['credA', 'target-a']]));
});
it('drops every binding when the scope has no requirements', () => {
expect(scopeCredentialBindingsToRequirements(bindings, undefined)).toEqual(new Map());
expect(scopeCredentialBindingsToRequirements(bindings, [])).toEqual(new Map());
});
it('keeps every binding when all are required by the scope', () => {
const scoped = scopeCredentialBindingsToRequirements(bindings, [
requirement('credA', ['W1']),
requirement('credB', ['W2']),
]);
expect(scoped).toEqual(bindings);
});
});

View File

@ -0,0 +1,162 @@
import type { WorkflowEntity } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import type { CredentialApplyResult } from '../../entities/credential/credential.types';
import type { DataTableImportRequest } from '../../entities/data-table/data-table.types';
import type { WorkflowImportOutcome } from '../../entities/workflow/workflow-import.types';
import type { ImportContext, ImportPackageRequest } from '../../n8n-packages.types';
import type { PackageManifest } from '../../spec/manifest.schema';
import type { PackageCredentialRequirement } from '../../spec/requirements.schema';
import type { ImportOrchestrationResult } from '../import-orchestrator';
import { emitPackageImportedEvent, type PackageImportScope } from '../import-telemetry';
const outcome = (
id: string,
sourceWorkflowId: string,
status: WorkflowImportOutcome['status'],
): WorkflowImportOutcome => ({
status,
sourceWorkflowId,
workflow: mock<WorkflowEntity>({ id }),
publishing: { state: 'unchanged' },
});
const requirement = (id: string): PackageCredentialRequirement => ({
id,
name: id,
type: 'githubApi',
usedByWorkflows: ['ignored'],
});
const scope = (input: {
projectId: string;
folderId?: string | null;
outcomes: WorkflowImportOutcome[];
credentialResult: CredentialApplyResult;
requirements?: PackageCredentialRequirement[];
dataTable?: { matched: number; created: number; requirements: number };
}): PackageImportScope => {
const context: ImportContext = {
user: mock(),
projectId: input.projectId,
folderId: input.folderId ?? null,
};
const dt = input.dataTable ?? { matched: 0, created: 0, requirements: 0 };
const imported: ImportOrchestrationResult = {
workflowOutcomes: input.outcomes,
folderSummaries: [],
bindings: { workflows: new Map(), credentials: new Map() },
credentialResult: input.credentialResult,
dataTablePlan: { creations: new Array(dt.created), failures: [], matchedCount: dt.matched },
};
return {
context,
imported,
credentialRequest: {
requirements: input.requirements,
matchingMode: 'id-only',
missingMode: 'create-stub',
credentialBindings: undefined,
},
dataTableRequest: mock<DataTableImportRequest>({
requirements: dt.requirements === 0 ? undefined : new Array(dt.requirements),
}),
};
};
const request = mock<ImportPackageRequest>({
user: mock(),
workflowConflictPolicy: 'new-version',
workflowIdPolicy: 'new',
credentialMatchingMode: 'id-only',
credentialMissingMode: 'create-stub',
workflowPublishingPolicy: 'preserve-published-state',
});
const manifest = mock<PackageManifest>({ sourceId: 'src-1', packageFormatVersion: '1' });
function lastImportedPayload(
eventService: ReturnType<typeof mock<EventService>>,
): RelayEventMap['n8n-package-imported'] {
expect(eventService.emit).toHaveBeenCalledTimes(1);
const [eventName, payload] = eventService.emit.mock.calls[0];
expect(eventName).toBe('n8n-package-imported');
return payload as RelayEventMap['n8n-package-imported'];
}
describe('emitPackageImportedEvent', () => {
it('aggregates counts, project ids and credential ids across every scope', () => {
const eventService = mock<EventService>();
emitPackageImportedEvent(eventService, {
request,
manifest,
scopes: [
scope({
projectId: 'P1',
folderId: 'F1',
outcomes: [outcome('wf1', 'WF1', 'created'), outcome('wf2', 'WF2', 'skipped')],
credentialResult: {
bindings: new Map([['credA', 'target-a']]),
matched: ['credA'],
stubbed: [],
},
requirements: [requirement('credA')],
dataTable: { matched: 1, created: 0, requirements: 1 },
}),
scope({
projectId: 'P2',
outcomes: [outcome('wf3', 'WF3', 'updated')],
credentialResult: {
bindings: new Map([['credB', 'stub-b']]),
matched: [],
stubbed: ['credB'],
},
requirements: [requirement('credB')],
dataTable: { matched: 0, created: 2, requirements: 2 },
}),
],
});
const payload = lastImportedPayload(eventService);
expect(payload.projectIds).toEqual(['P1', 'P2']);
// Skipped workflows are excluded; only wf1 and wf3 were actually written.
expect(payload.workflowIds).toEqual(['wf1', 'wf3']);
// A multi-scope import has no single folder to attribute the event to.
expect(payload.folderId).toBeNull();
// Credential ids are resolved through each scope's binding map (source id -> target id).
expect(payload.credentialIds).toEqual({
matched: ['target-a'],
created: ['stub-b'],
updated: [],
});
expect(payload.counts).toEqual({
workflows: { created: 1, updated: 1, skipped: 1 },
credentials: { matched: 1, created: 1, requirements: 2 },
dataTables: { matched: 1, created: 2, requirements: 3 },
});
expect(payload.packageSourceId).toBe('src-1');
});
it('preserves the folder id for a single-scope import', () => {
const eventService = mock<EventService>();
emitPackageImportedEvent(eventService, {
request,
manifest,
scopes: [
scope({
projectId: 'P1',
folderId: 'F1',
outcomes: [outcome('wf1', 'WF1', 'created')],
credentialResult: { bindings: new Map(), matched: [], stubbed: [] },
}),
],
});
expect(lastImportedPayload(eventService).folderId).toBe('F1');
});
});

View File

@ -78,5 +78,18 @@ describe('package-layout', () => {
it('returns null for a project-root workflow even when its project has no folder entry', () => {
expect(deriveParentFolderId('projects/unknown/workflows/wf', map)).toBeNull();
});
it('splits on the LAST /workflows/ so a folder literally named "workflows" resolves correctly', () => {
// A folder named "workflows" keeps its bare slug when its parent has no directly-contained
// workflows; a workflow inside it must resolve to that folder, not its grandparent.
const withWorkflowsFolder = new Map([
['folders/a', 'A'],
['folders/a/workflows', 'W'],
]);
expect(deriveParentFolderId('folders/a/workflows/workflows/wf', withWorkflowsFolder)).toBe(
'W',
);
expect(deriveParentFolderId('folders/a/workflows/wf', withWorkflowsFolder)).toBe('A');
});
});
});

View File

@ -1,5 +1,6 @@
import { Service } from '@n8n/di';
import { toImportBlockedError } from './import-blocked.error';
import { CredentialImporter } from '../entities/credential/credential-importer';
import { workflowsBlockedFromPublish } from '../entities/credential/credential-missing-mode';
import type {
@ -13,8 +14,12 @@ import type {
DataTableImportPlan,
DataTableImportRequest,
} from '../entities/data-table/data-table.types';
import type {
FolderImportContext,
FolderImportPlan,
PreparedFolder,
} from '../entities/folder/folder-import.types';
import { FolderImporter } from '../entities/folder/folder-importer';
import type { FolderImportPlan, PreparedFolder } from '../entities/folder/folder-import.types';
import type {
PreparedWorkflow,
WorkflowImportOutcome,
@ -31,7 +36,6 @@ import type {
ImportWorkflowProperties,
PackageImportBindings,
} from '../n8n-packages.types';
import { toImportBlockedError } from './import-blocked.error';
export interface ImportOrchestrationInput {
context: ImportContext;
@ -40,6 +44,8 @@ export interface ImportOrchestrationInput {
credentialRequest: CredentialBindingRequest;
dataTableRequest: DataTableImportRequest;
options: ImportWorkflowProperties & ImportFolderProperties;
/** The target project does not exist yet and will be created by this import (project packages). */
projectPendingCreation?: boolean;
}
export interface ImportOrchestrationResult {
@ -50,6 +56,16 @@ export interface ImportOrchestrationResult {
dataTablePlan: DataTableImportPlan;
}
export interface ImportPlan {
input: ImportOrchestrationInput;
folderContext: FolderImportContext;
credentialPlan: CredentialResolution;
workflowPlan: WorkflowImportPlan;
folderPlan: FolderImportPlan;
dataTablePlan: DataTableImportPlan;
blockingIssues: BlockingIssue[];
}
/**
* Coordinates the credential, folder, and workflow importers to bring a package's
* contents into one resolved project scope
@ -65,13 +81,21 @@ export class ImportOrchestrator {
) {}
async import(input: ImportOrchestrationInput): Promise<ImportOrchestrationResult> {
const plan = await this.plan(input);
if (plan.blockingIssues.length > 0) {
throw toImportBlockedError(plan.blockingIssues);
}
return await this.apply(plan);
}
async plan(input: ImportOrchestrationInput): Promise<ImportPlan> {
const { context, folders, workflows, credentialRequest, dataTableRequest, options } = input;
// PublishAll requires publish scope up front; other policies are checked per workflow.
await this.workflowPublisher.assertCanPublish(
context.user,
context.projectId,
options.workflowPublishingPolicy,
input.projectPendingCreation,
);
const credentialPlan = await this.credentialImporter.plan(context, credentialRequest);
@ -88,9 +112,20 @@ export class ImportOrchestrator {
dataTablePlan,
});
if (blockingIssues.length > 0) {
throw toImportBlockedError(blockingIssues);
}
return {
input,
folderContext,
credentialPlan,
workflowPlan,
folderPlan,
dataTablePlan,
blockingIssues,
};
}
async apply(plan: ImportPlan): Promise<ImportOrchestrationResult> {
const { input, folderContext, credentialPlan, workflowPlan, folderPlan, dataTablePlan } = plan;
const { context, credentialRequest, options } = input;
const folderSummaries = await this.folderImporter.apply(folderContext, folderPlan);

View File

@ -1,16 +1,22 @@
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import type { WorkflowImportOutcome } from '../entities/workflow/workflow-import.types';
import type {
PreparedWorkflow,
WorkflowImportOutcome,
} from '../entities/workflow/workflow-import.types';
import { serializeBindings } from '../n8n-packages.types';
import type {
ImportBindingMap,
ImportCredentialSummary,
ImportedFolderSummary,
ImportedProjectSummary,
ImportedWorkflowSummary,
ImportPackageSummary,
ImportResult,
PackageImportBindings,
} from '../n8n-packages.types';
import type { PackageManifest } from '../spec/manifest.schema';
import type { PackageCredentialRequirement } from '../spec/requirements.schema';
export function toPackageSummary(manifest: PackageManifest): ImportPackageSummary {
return {
@ -20,11 +26,25 @@ export function toPackageSummary(manifest: PackageManifest): ImportPackageSummar
};
}
/** Assembles the wire {@link ImportResult} from each entity importer's outcome. */
export function toImportedWorkflowSummaries(
outcomes: WorkflowImportOutcome[],
projectId: string,
): ImportedWorkflowSummary[] {
return outcomes.map(({ workflow, sourceWorkflowId, status, publishing }) => ({
sourceWorkflowId,
localId: workflow.id,
name: workflow.name,
projectId,
parentFolderId: workflow.parentFolder?.id ?? null,
activeVersionId: workflow.activeVersionId ?? null,
publishing,
status,
}));
}
export function buildImportResult(input: {
package: ImportPackageSummary;
projectId: string | null;
workflows: WorkflowImportOutcome[];
workflows: ImportedWorkflowSummary[];
folders: ImportedFolderSummary[];
projects: ImportedProjectSummary[];
bindings: PackageImportBindings;
@ -32,16 +52,7 @@ export function buildImportResult(input: {
}): ImportResult {
return {
package: input.package,
workflows: input.workflows.map(({ workflow, sourceWorkflowId, status, publishing }) => ({
sourceWorkflowId,
localId: workflow.id,
name: workflow.name,
projectId: input.projectId ?? '',
parentFolderId: workflow.parentFolder?.id ?? null,
activeVersionId: workflow.activeVersionId ?? null,
publishing,
status,
})),
workflows: input.workflows,
folders: input.folders,
projects: input.projects,
bindings: serializeBindings(input.bindings),
@ -64,3 +75,34 @@ export function assertPackageImportApiKeyScopes(
}
}
}
/** Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match. */
export function identifyRequirements<T extends { usedByWorkflows: string[] }>(
requirements: T[] | undefined,
workflows: PreparedWorkflow[],
): T[] | undefined {
if (!requirements) return undefined;
const importedIds = new Set(workflows.map((workflow) => workflow.sourceWorkflowId));
return requirements
.map((requirement) => ({
...requirement,
usedByWorkflows: requirement.usedByWorkflows.filter((id) => importedIds.has(id)),
}))
.filter((requirement) => requirement.usedByWorkflows.length > 0);
}
/**
* Restricts explicit credential bindings to those a scope's requirements declare. A project package
* shares one binding map across every project, but each project only sees its own requirements without
* this, a binding for a credential used solely in another project looks orphaned and blocks the import.
*/
export function scopeCredentialBindingsToRequirements(
bindings: ImportBindingMap | undefined,
requirements: PackageCredentialRequirement[] | undefined,
): ImportBindingMap | undefined {
if (!bindings) return undefined;
const requirementIds = new Set((requirements ?? []).map((requirement) => requirement.id));
return new Map([...bindings].filter(([sourceId]) => requirementIds.has(sourceId)));
}

View File

@ -0,0 +1,97 @@
import type { EventService } from '@/events/event.service';
import type { CredentialBindingRequest } from '../entities/credential/credential.types';
import type { DataTableImportRequest } from '../entities/data-table/data-table.types';
import type { WorkflowImportOutcome } from '../entities/workflow/workflow-import.types';
import type { ImportContext, ImportPackageRequest } from '../n8n-packages.types';
import type { ImportOrchestrationResult } from './import-orchestrator';
import type { PackageManifest } from '../spec/manifest.schema';
export interface PackageImportScope {
context: ImportContext;
imported: ImportOrchestrationResult;
credentialRequest: CredentialBindingRequest;
dataTableRequest: DataTableImportRequest;
}
export function emitPackageImportedEvent(
eventService: EventService,
params: {
request: ImportPackageRequest;
manifest: PackageManifest;
scopes: PackageImportScope[];
},
): void {
const { request, manifest, scopes } = params;
const workflowOutcomes = scopes.flatMap(({ imported }) => imported.workflowOutcomes);
const credentialResults = scopes.map(({ imported }) => imported.credentialResult);
const importedWorkflows = workflowOutcomes.filter(({ status }) => status !== 'skipped');
const countByStatus = (status: WorkflowImportOutcome['status']) =>
workflowOutcomes.filter((outcome) => outcome.status === status).length;
const credentialRequirements = scopes.reduce(
(total, { credentialRequest }) => total + (credentialRequest.requirements?.length ?? 0),
0,
);
const matchedCredentialIds = credentialResults.flatMap(({ matched, bindings }) =>
matched.map((sourceId) => bindings.get(sourceId)!),
);
const createdCredentialIds = credentialResults.flatMap(({ stubbed, bindings }) =>
stubbed.map((sourceId) => bindings.get(sourceId)!),
);
const dataTablePlans = scopes.map(({ imported }) => imported.dataTablePlan);
const dataTableRequirements = scopes.reduce(
(total, { dataTableRequest }) => total + (dataTableRequest.requirements?.length ?? 0),
0,
);
const dataTablesMatched = dataTablePlans.reduce((total, plan) => total + plan.matchedCount, 0);
const dataTablesCreated = dataTablePlans.reduce(
(total, plan) => total + plan.creations.length,
0,
);
const folderId = scopes.length === 1 ? scopes[0].context.folderId : null;
eventService.emit('n8n-package-imported', {
user: request.user,
projectIds: scopes.map(({ context }) => context.projectId),
folderId,
workflowIds: importedWorkflows.map(({ workflow }) => workflow.id),
options: {
workflowConflictPolicy: request.workflowConflictPolicy,
workflowIdPolicy: request.workflowIdPolicy,
credentialMatchingMode: request.credentialMatchingMode,
credentialMissingMode: request.credentialMissingMode,
workflowPublishingPolicy: request.workflowPublishingPolicy,
dataTableMatchingMode: request.dataTableMatchingMode,
dataTableMissingMode: request.dataTableMissingMode,
dataTableSchemaConflictPolicy: request.dataTableSchemaConflictPolicy,
},
packageSourceId: manifest.sourceId,
packageVersion: manifest.packageFormatVersion,
credentialIds: {
matched: matchedCredentialIds,
created: createdCredentialIds,
updated: [],
},
counts: {
workflows: {
created: countByStatus('created'),
updated: countByStatus('updated'),
skipped: countByStatus('skipped'),
},
credentials: {
matched: matchedCredentialIds.length,
created: createdCredentialIds.length,
requirements: credentialRequirements,
},
dataTables: {
matched: dataTablesMatched,
created: dataTablesCreated,
requirements: dataTableRequirements,
},
},
});
}

View File

@ -17,7 +17,7 @@ import { packageManifestSchema } from '../spec/manifest.schema';
import { serializedDataTableSchema } from '../spec/serialized/data-table.schema';
import type { SerializedDataTable } from '../spec/serialized/data-table.schema';
import { serializedFolderSchema, type SerializedFolder } from '../spec/serialized/folder.schema';
import { serializedProjectSchema } from '../spec/serialized/project.schema';
import { serializedProjectSchema, type SerializedProject } from '../spec/serialized/project.schema';
import type { SerializedWorkflow } from '../spec/serialized/workflow.schema';
/**
@ -180,20 +180,30 @@ export class N8nPackageParser {
const path = `${entry.target}/project.json`;
const wire = await this.readJson(reader, path, 'project');
let project: SerializedProject;
try {
const project = serializedProjectSchema.parse(wire);
return {
sourceProjectId: project.id,
name: project.name,
...(project.description !== undefined ? { description: project.description } : {}),
...(project.icon !== undefined ? { icon: project.icon } : {}),
};
project = serializedProjectSchema.parse(wire);
} catch (cause) {
if (cause instanceof ZodError) {
throw new UserError(`Package project file at ${path} failed schema validation.`, { cause });
}
throw cause;
}
// Project contents scope by manifest id, but the project is created/matched under project.json's
// id — a mismatch would import folders and workflows into the wrong project.
if (project.id !== entry.id) {
throw new UserError(
`Package project at ${path} declares id "${project.id}" but the manifest lists it as "${entry.id}".`,
);
}
return {
sourceProjectId: project.id,
name: project.name,
...(project.description !== undefined ? { description: project.description } : {}),
...(project.icon !== undefined ? { icon: project.icon } : {}),
};
}
private async readJson<T = unknown>(

View File

@ -1,25 +1,48 @@
import { LicenseState } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { EventService } from '@/events/event.service';
import type { CredentialBindingRequest } from '../entities/credential/credential.types';
import type { DataTableImportRequest } from '../entities/data-table/data-table.types';
import { ProjectImporter } from '../entities/project/project-importer';
import type { PackageReader } from '../io/package-reader';
import { createBindings } from '../n8n-packages.types';
import type { ImportPackageRequest, ImportResult } from '../n8n-packages.types';
import type {
BlockingIssue,
ImportedFolderSummary,
ImportedWorkflowSummary,
ImportPackageRequest,
ImportResult,
PackageImportBindings,
} from '../n8n-packages.types';
import { mergeBindings } from '../n8n-packages.types';
import { toImportBlockedError } from './import-blocked.error';
import {
ImportOrchestrator,
type ImportOrchestrationInput,
type ImportPlan,
} from './import-orchestrator';
import {
assertPackageImportApiKeyScopes,
buildImportResult,
identifyRequirements,
scopeCredentialBindingsToRequirements,
toImportedWorkflowSummaries,
toPackageSummary,
} from './import-result';
import { emitPackageImportedEvent, type PackageImportScope } from './import-telemetry';
import { N8nPackageParser } from './n8n-package-parser';
import type { PackageManifest } from '../spec/manifest.schema';
import type { ManifestEntry, PackageManifest } from '../spec/manifest.schema';
/**
* Imports a package containing projects into the target instance.
**/
@Service()
export class ProjectPackageImporter {
constructor(
private readonly packageParser: N8nPackageParser,
private readonly projectImporter: ProjectImporter,
private readonly importOrchestrator: ImportOrchestrator,
private readonly eventService: EventService,
private readonly licenseState: LicenseState,
) {}
async import(
@ -27,19 +50,138 @@ export class ProjectPackageImporter {
reader: PackageReader,
manifest: PackageManifest,
): Promise<ImportResult> {
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['project:create', 'project:update']);
this.assertAdequatePermissions(request, manifest);
const projects = await this.packageParser.getProjects(reader);
const plan = await this.projectImporter.plan(request.user, projects);
const projectSummaries = await this.projectImporter.apply(request.user, plan);
const projectPlan = await this.projectImporter.plan(request.user, projects);
// Projects the user is creating (vs matching an existing one). They will be admin of these,
// so publish is always allowed and the project need not exist while its contents are planned.
const pendingCreateIds = new Set(
projectPlan.filter((item) => item.action === 'create').map((item) => item.sourceProjectId),
);
// Plan and validate every project's contents before writing anything, so a blocking issue in
// any project leaves nothing behind — not folders, workflows, nor the project shells.
const planned: Array<{ project: ManifestEntry; plan: ImportPlan }> = [];
const blockingIssues: BlockingIssue[] = [];
for (const project of manifest.projects ?? []) {
const input = await this.buildImportContextForProject(
request,
reader,
manifest,
project,
pendingCreateIds.has(project.id),
);
const plan = await this.importOrchestrator.plan(input);
planned.push({ project, plan });
blockingIssues.push(...plan.blockingIssues);
}
if (blockingIssues.length > 0) {
throw toImportBlockedError(blockingIssues);
}
const projectSummaries = await this.projectImporter.apply(request.user, projectPlan);
const workflows: ImportedWorkflowSummary[] = [];
const folders: ImportedFolderSummary[] = [];
const scopedBindings: PackageImportBindings[] = [];
const matched: string[] = [];
const stubbed: string[] = [];
const scopes: PackageImportScope[] = [];
for (const { project, plan } of planned) {
const imported = await this.importOrchestrator.apply(plan);
workflows.push(...toImportedWorkflowSummaries(imported.workflowOutcomes, project.id));
folders.push(...imported.folderSummaries);
scopedBindings.push(imported.bindings);
matched.push(...imported.credentialResult.matched);
stubbed.push(...imported.credentialResult.stubbed);
scopes.push({
context: plan.input.context,
imported,
credentialRequest: plan.input.credentialRequest,
dataTableRequest: plan.input.dataTableRequest,
});
}
emitPackageImportedEvent(this.eventService, { request, manifest, scopes });
return buildImportResult({
package: toPackageSummary(manifest),
projectId: null,
workflows: [],
folders: [],
workflows,
folders,
projects: projectSummaries,
bindings: createBindings(),
bindings: mergeBindings(...scopedBindings),
credentials: { matched, stubbed },
});
}
private async buildImportContextForProject(
request: ImportPackageRequest,
reader: PackageReader,
manifest: PackageManifest,
project: ManifestEntry,
projectPendingCreation: boolean,
): Promise<ImportOrchestrationInput> {
const basePrefix = `${project.target}/`;
const folders = await this.packageParser.getFolders(reader, basePrefix);
const workflows = await this.packageParser.getWorkflows(reader, basePrefix);
// Requirements and bindings are both scoped to this project's workflows so another project's
// binding is not seen as an orphan here (which would block the whole multi-project import).
const requirements = identifyRequirements(manifest.requirements?.credentials, workflows);
const credentialRequest: CredentialBindingRequest = {
requirements,
matchingMode: request.credentialMatchingMode,
missingMode: request.credentialMissingMode,
credentialBindings: scopeCredentialBindingsToRequirements(
request.bindings?.credentials,
requirements,
),
};
const dataTableRequest: DataTableImportRequest = {
requirements: identifyRequirements(manifest.requirements?.dataTables, workflows),
packageDataTables: await this.packageParser.getDataTables(reader),
matchingMode: request.dataTableMatchingMode,
missingMode: request.dataTableMissingMode,
schemaConflictPolicy: request.dataTableSchemaConflictPolicy,
};
return {
context: {
user: request.user,
projectId: project.id,
folderId: null,
},
folders,
workflows,
credentialRequest,
dataTableRequest,
options: request,
projectPendingCreation,
};
}
private assertAdequatePermissions(
request: ImportPackageRequest,
manifest: PackageManifest,
): void {
// A project package can create new projects or update matched ones (by source id), so require both —
// mirroring the folder create+update assertion below.
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['project:create', 'project:update']);
if ((manifest.folders?.length ?? 0) > 0) {
if (!this.licenseState.isLicensed('feat:folders')) {
throw new ForbiddenError(
'Your license does not allow folders. Importing a package with folders requires a license that supports folders.',
);
}
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['folder:create', 'folder:update']);
}
if ((manifest.workflows?.length ?? 0) > 0) {
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['workflow:import']);
}
}
}

View File

@ -12,20 +12,19 @@ import { ProjectService } from '@/services/project.service.ee';
import type { CredentialBindingRequest } from '../entities/credential/credential.types';
import type { DataTableImportRequest } from '../entities/data-table/data-table.types';
import type {
PreparedWorkflow,
WorkflowImportOutcome,
} from '../entities/workflow/workflow-import.types';
import type { ImportContext, ImportPackageRequest, ImportResult } from '../n8n-packages.types';
import type { PackageReader } from '../io/package-reader';
import type { PackageManifest } from '../spec/manifest.schema';
import { ImportOrchestrator, type ImportOrchestrationResult } from './import-orchestrator';
import type { ImportContext, ImportPackageRequest, ImportResult } from '../n8n-packages.types';
import { ImportOrchestrator } from './import-orchestrator';
import {
assertPackageImportApiKeyScopes,
buildImportResult,
identifyRequirements,
toImportedWorkflowSummaries,
toPackageSummary,
} from './import-result';
import { emitPackageImportedEvent } from './import-telemetry';
import { N8nPackageParser } from './n8n-package-parser';
import type { PackageManifest } from '../spec/manifest.schema';
/**
* Imports loose top-level workflows, their folder shells, and credential & data table deps into a target project.
@ -92,19 +91,15 @@ export class WorkflowPackageImporter {
options: request,
});
this.emitImportedEvent(
emitPackageImportedEvent(this.eventService, {
request,
context,
manifest,
imported,
credentialRequest,
dataTableRequest,
);
scopes: [{ context, imported, credentialRequest, dataTableRequest }],
});
return buildImportResult({
package: toPackageSummary(manifest),
projectId: context.projectId,
workflows: imported.workflowOutcomes,
workflows: toImportedWorkflowSummaries(imported.workflowOutcomes, context.projectId),
folders: imported.folderSummaries,
projects: [],
bindings: imported.bindings,
@ -123,65 +118,6 @@ export class WorkflowPackageImporter {
}
}
private emitImportedEvent(
request: ImportPackageRequest,
context: ImportContext,
manifest: PackageManifest,
imported: ImportOrchestrationResult,
credentialRequest: CredentialBindingRequest,
dataTableRequest: DataTableImportRequest,
): void {
const { workflowOutcomes, credentialResult, dataTablePlan } = imported;
const importedWorkflows = workflowOutcomes.filter(({ status }) => status !== 'skipped');
const countByStatus = (status: WorkflowImportOutcome['status']) =>
workflowOutcomes.filter((outcome) => outcome.status === status).length;
this.eventService.emit('n8n-package-imported', {
user: context.user,
projectId: context.projectId,
folderId: context.folderId,
workflowIds: importedWorkflows.map(({ workflow }) => workflow.id),
options: {
workflowConflictPolicy: request.workflowConflictPolicy,
workflowIdPolicy: request.workflowIdPolicy,
credentialMatchingMode: request.credentialMatchingMode,
credentialMissingMode: request.credentialMissingMode,
workflowPublishingPolicy: request.workflowPublishingPolicy,
dataTableMatchingMode: request.dataTableMatchingMode,
dataTableMissingMode: request.dataTableMissingMode,
dataTableSchemaConflictPolicy: request.dataTableSchemaConflictPolicy,
},
packageSourceId: manifest.sourceId,
packageVersion: manifest.packageFormatVersion,
credentialIds: {
matched: credentialResult.matched.map(
(sourceId) => credentialResult.bindings.get(sourceId)!,
),
created: credentialResult.stubbed.map(
(sourceId) => credentialResult.bindings.get(sourceId)!,
),
updated: [],
},
counts: {
workflows: {
created: countByStatus('created'),
updated: countByStatus('updated'),
skipped: countByStatus('skipped'),
},
credentials: {
matched: credentialResult.matched.length,
created: credentialResult.stubbed.length,
requirements: credentialRequest.requirements?.length ?? 0,
},
dataTables: {
matched: dataTablePlan.matchedCount,
created: dataTablePlan.creations.length,
requirements: dataTableRequest.requirements?.length ?? 0,
},
},
});
}
private async findImportLocation(
user: User,
projectId: string | undefined,
@ -236,19 +172,3 @@ export class WorkflowPackageImporter {
}
}
}
/** Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match. */
function identifyRequirements<T extends { usedByWorkflows: string[] }>(
requirements: T[] | undefined,
workflows: PreparedWorkflow[],
): T[] | undefined {
if (!requirements) return undefined;
const importedIds = new Set(workflows.map((workflow) => workflow.sourceWorkflowId));
return requirements
.map((requirement) => ({
...requirement,
usedByWorkflows: requirement.usedByWorkflows.filter((id) => importedIds.has(id)),
}))
.filter((requirement) => requirement.usedByWorkflows.length > 0);
}

View File

@ -41,6 +41,18 @@ describe('WorkflowPublisher', () => {
expect(projectService.getProjectWithScope).not.toHaveBeenCalled();
});
it('does nothing for a pending-create project even under publish-all', async () => {
// The project does not exist yet; its creator will be admin, so there is nothing to check.
await publisher.assertCanPublish(
user,
'new-project',
WorkflowPublishingPolicy.PublishAll,
true,
);
expect(projectService.getProjectWithScope).not.toHaveBeenCalled();
});
it('passes when the user can publish in the target project', async () => {
projectService.getProjectWithScope.mockResolvedValue(mock<Project>({ id: 'project-1' }));

View File

@ -40,16 +40,25 @@ export class WorkflowPublisher {
* Fail the import before any writes when {@link WorkflowPublishingPolicy.PublishAll}
* is selected and the actor lacks `workflow:publish`. Other policies skip this check;
* publish permission is checked per workflow in workflowService
*
* `projectPendingCreation` lets this run before the target project exists: a project the
* user is importing as new will be created with them as admin, so they can always publish
* in it and there is nothing to look up yet.
*/
async assertCanPublish(
user: User,
projectId: string,
policy: WorkflowPublishingPolicy,
projectPendingCreation = false,
): Promise<void> {
if (policy !== WorkflowPublishingPolicy.PublishAll) {
return;
}
if (projectPendingCreation) {
return;
}
const project = await this.projectService.getProjectWithScope(user, projectId, [
'workflow:publish',
]);

View File

@ -269,6 +269,14 @@ export function createBindings(seed: Partial<PackageImportBindings> = {}): Packa
};
}
/** Combines per-scope binding maps into one — used when a project package imports several scopes. */
export function mergeBindings(...bindings: PackageImportBindings[]): PackageImportBindings {
return {
workflows: new Map(bindings.flatMap(({ workflows }) => [...workflows])),
credentials: new Map(bindings.flatMap(({ credentials }) => [...credentials])),
};
}
/** Plain-object form of {@link PackageImportBindings}, suitable for JSON responses. */
export type SerializedBindings = Record<keyof PackageImportBindings, Record<string, string>>;