diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index b70f5a969d2..9fb5fbb4d56 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -40,7 +40,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) | 4 | | BASE TABLE | | [public.annotation_tag_entity](public.annotation_tag_entity.md) | 4 | | BASE TABLE | | [public.app](public.app.md) | 8 | | BASE TABLE | -| [public.app_version](public.app_version.md) | 7 | | BASE TABLE | +| [public.app_version](public.app_version.md) | 9 | | BASE TABLE | | [public.auth_identity](public.auth_identity.md) | 5 | | BASE TABLE | | [public.auth_provider_sync_history](public.auth_provider_sync_history.md) | 11 | | BASE TABLE | | [public.binary_data](public.binary_data.md) | 9 | | BASE TABLE | @@ -733,8 +733,10 @@ erDiagram "public.app_version" { varchar_36_ appId FK timestamp_3__with_time_zone createdAt + integer distSizeBytes varchar_255_ distStorageKey varchar_36_ id + integer sourceSizeBytes varchar_255_ sourceStorageKey varchar_8_ storedAt timestamp_3__with_time_zone updatedAt diff --git a/docs/generated/postgres-schema/public.app.md b/docs/generated/postgres-schema/public.app.md index 5cbe2585046..36ca635acf3 100644 --- a/docs/generated/postgres-schema/public.app.md +++ b/docs/generated/postgres-schema/public.app.md @@ -57,8 +57,10 @@ erDiagram "public.app_version" { varchar_36_ appId FK timestamp_3__with_time_zone createdAt + integer distSizeBytes varchar_255_ distStorageKey varchar_36_ id + integer sourceSizeBytes varchar_255_ sourceStorageKey varchar_8_ storedAt timestamp_3__with_time_zone updatedAt diff --git a/docs/generated/postgres-schema/public.app_version.md b/docs/generated/postgres-schema/public.app_version.md index 8250e9faee3..262b668df8a 100644 --- a/docs/generated/postgres-schema/public.app_version.md +++ b/docs/generated/postgres-schema/public.app_version.md @@ -6,8 +6,10 @@ | ---- | ---- | ------- | -------- | -------- | ------- | ------- | | appId | varchar(36) | | false | | [public.app](public.app.md) | | | createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| distSizeBytes | integer | | true | | | Size of the dist tarball, in bytes; null once pruned by retention | | distStorageKey | varchar(255) | | true | | | Blob key of the built dist tarball; null once pruned by retention | | id | varchar(36) | | false | [public.app](public.app.md) | | | +| sourceSizeBytes | integer | 0 | false | | | Size of the source tarball, in bytes | | sourceStorageKey | varchar(255) | | false | | | Blob key of the source tarball (project minus node_modules, dist, .git) | | storedAt | varchar(8) | | false | | | Execution data storage mode the tarballs were written with | | updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | @@ -22,6 +24,7 @@ | app_version_appId_not_null | n | NOT NULL "appId" | | app_version_createdAt_not_null | n | NOT NULL "createdAt" | | app_version_id_not_null | n | NOT NULL id | +| app_version_sourceSizeBytes_not_null | n | NOT NULL "sourceSizeBytes" | | app_version_sourceStorageKey_not_null | n | NOT NULL "sourceStorageKey" | | app_version_storedAt_not_null | n | NOT NULL "storedAt" | | app_version_updatedAt_not_null | n | NOT NULL "updatedAt" | @@ -44,8 +47,10 @@ erDiagram "public.app_version" { varchar_36_ appId FK timestamp_3__with_time_zone createdAt + integer distSizeBytes varchar_255_ distStorageKey varchar_36_ id + integer sourceSizeBytes varchar_255_ sourceStorageKey varchar_8_ storedAt timestamp_3__with_time_zone updatedAt diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 3bd0489a76f..3d69ba21c51 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -40,7 +40,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) | 4 | | table | | [annotation_tag_entity](annotation_tag_entity.md) | 4 | | table | | [app](app.md) | 8 | | table | -| [app_version](app_version.md) | 7 | | table | +| [app_version](app_version.md) | 9 | | table | | [auth_identity](auth_identity.md) | 5 | | table | | [auth_provider_sync_history](auth_provider_sync_history.md) | 11 | | table | | [binary_data](binary_data.md) | 9 | | table | @@ -720,8 +720,10 @@ erDiagram "app_version" { varchar_36_ appId FK datetime_3_ createdAt + INTEGER distSizeBytes varchar_255_ distStorageKey varchar_36_ id PK + INTEGER sourceSizeBytes varchar_255_ sourceStorageKey varchar_8_ storedAt datetime_3_ updatedAt diff --git a/docs/generated/sqlite-schema/app.md b/docs/generated/sqlite-schema/app.md index 085a6720c5d..cc5b0e9ea2f 100644 --- a/docs/generated/sqlite-schema/app.md +++ b/docs/generated/sqlite-schema/app.md @@ -63,8 +63,10 @@ erDiagram "app_version" { varchar_36_ appId FK datetime_3_ createdAt + INTEGER distSizeBytes varchar_255_ distStorageKey varchar_36_ id PK + INTEGER sourceSizeBytes varchar_255_ sourceStorageKey varchar_8_ storedAt datetime_3_ updatedAt diff --git a/docs/generated/sqlite-schema/app_version.md b/docs/generated/sqlite-schema/app_version.md index 5a314c1021a..9f5ce6d24da 100644 --- a/docs/generated/sqlite-schema/app_version.md +++ b/docs/generated/sqlite-schema/app_version.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "app_version" ("id" varchar(36) PRIMARY KEY NOT NULL, "appId" varchar(36) NOT NULL, "storedAt" varchar(8) NOT NULL, "sourceStorageKey" varchar(255) NOT NULL, "distStorageKey" varchar(255), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_app_version_storedAt" CHECK ("storedAt" IN ('db', 'fs', 's3', 'az')), CONSTRAINT "FK_e9aeab5b1db8dc77708231ae44e" FOREIGN KEY ("appId") REFERENCES "app" ("id") ON DELETE CASCADE) +CREATE TABLE "app_version" ("id" varchar(36) PRIMARY KEY NOT NULL, "appId" varchar(36) NOT NULL, "storedAt" varchar(8) NOT NULL, "sourceStorageKey" varchar(255) NOT NULL, "distStorageKey" varchar(255), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "sourceSizeBytes" integer NOT NULL DEFAULT (0), "distSizeBytes" integer, CONSTRAINT "CHK_app_version_storedAt" CHECK (("storedAt" IN ('db', 'fs', 's3', 'az'))), CONSTRAINT "FK_e9aeab5b1db8dc77708231ae44e" FOREIGN KEY ("appId") REFERENCES "app" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) ``` @@ -17,8 +17,10 @@ CREATE TABLE "app_version" ("id" varchar(36) PRIMARY KEY NOT NULL, "appId" varch | ---- | ---- | ------- | -------- | -------- | ------- | ------- | | appId | varchar(36) | | false | | [app](app.md) | | | createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| distSizeBytes | INTEGER | | true | | | | | distStorageKey | varchar(255) | | true | | | | | id | varchar(36) | | false | [app](app.md) | | | +| sourceSizeBytes | INTEGER | 0 | false | | | | | sourceStorageKey | varchar(255) | | false | | | | | storedAt | varchar(8) | | false | | | | | updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | @@ -27,7 +29,7 @@ CREATE TABLE "app_version" ("id" varchar(36) PRIMARY KEY NOT NULL, "appId" varch | Name | Type | Definition | | ---- | ---- | ---------- | -| - | CHECK | CHECK ("storedAt" IN ('db', 'fs', 's3', 'az')) | +| - | CHECK | CHECK (("storedAt" IN ('db', 'fs', 's3', 'az'))) | | - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (appId) REFERENCES app (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | | id | PRIMARY KEY | PRIMARY KEY (id) | | sqlite_autoindex_app_version_1 | PRIMARY KEY | PRIMARY KEY (id) | @@ -50,8 +52,10 @@ erDiagram "app_version" { varchar_36_ appId FK datetime_3_ createdAt + INTEGER distSizeBytes varchar_255_ distStorageKey varchar_36_ id PK + INTEGER sourceSizeBytes varchar_255_ sourceStorageKey varchar_8_ storedAt datetime_3_ updatedAt diff --git a/packages/@n8n/config/src/configs/apps.config.ts b/packages/@n8n/config/src/configs/apps.config.ts new file mode 100644 index 00000000000..13c855f1616 --- /dev/null +++ b/packages/@n8n/config/src/configs/apps.config.ts @@ -0,0 +1,16 @@ +import { Config, Env } from '../decorators'; + +@Config +export class AppsConfig { + /** Maximum number of Apps a single project may have. */ + @Env('N8N_APPS_MAX_PER_PROJECT') + maxAppsPerProject: number = 20; + + /** Maximum number of versions a single App may accumulate. */ + @Env('N8N_APPS_MAX_VERSIONS_PER_APP') + maxVersionsPerApp: number = 50; + + /** Maximum total bytes (source + dist, across all versions) a project's apps may occupy. */ + @Env('N8N_APPS_MAX_PROJECT_BLOB_SIZE_BYTES') + maxProjectBlobSize: number = 500 * 1024 * 1024; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index 96e6c416d6c..d50351432b7 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -6,6 +6,7 @@ import { AiAssistantConfig } from './configs/ai-assistant.config'; import { AiBuilderConfig } from './configs/ai-builder.config'; import { AiGatewayConfig } from './configs/ai-gateway.config'; import { AiConfig } from './configs/ai.config'; +import { AppsConfig } from './configs/apps.config'; import { AuthConfig } from './configs/auth.config'; import { CacheConfig } from './configs/cache.config'; import { ChatHubConfig } from './configs/chat-hub.config'; @@ -59,6 +60,7 @@ import { Config, Env, Nested } from './decorators'; export { Config, Env, Nested } from './decorators'; export { AiConfig } from './configs/ai.config'; +export { AppsConfig } from './configs/apps.config'; export { DatabaseConfig, SqliteConfig } from './configs/database.config'; export { InstanceSettingsConfig } from './configs/instance-settings-config'; export { InstanceSettingsLoaderConfig } from './configs/instance-settings-loader.config'; @@ -294,6 +296,9 @@ export class GlobalConfig { @Nested dataTable: DataTableConfig; + @Nested + apps: AppsConfig; + @Nested workflowHistoryCompaction: WorkflowHistoryCompactionConfig; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index d6ae07a2f29..92d35e58dfc 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -88,6 +88,11 @@ describe('GlobalConfig', () => { fileMaxAgeMs: 2 * 60 * 1000, uploadDir: path.join(tmpdir(), 'n8nDataTableUploads'), }, + apps: { + maxAppsPerProject: 20, + maxVersionsPerApp: 50, + maxProjectBlobSize: 500 * 1024 * 1024, + }, database: { logging: { enabled: false, diff --git a/packages/@n8n/db/src/migrations/common/1788884672989-AddSizeColumnsToAppVersion.ts b/packages/@n8n/db/src/migrations/common/1788884672989-AddSizeColumnsToAppVersion.ts new file mode 100644 index 00000000000..148ac0ef659 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1788884672989-AddSizeColumnsToAppVersion.ts @@ -0,0 +1,33 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const APP_VERSION_TABLE = 'app_version'; + +/** + * Byte sizes of each version's tarballs, so a project's total app storage can + * be summed without reading blobs back from storage. + * + * Known limitation: rows that predate this migration default to 0/null, so + * the quota sum ignores their real size until a separate backfill lands. + */ +export class AddSizeColumnsToAppVersion1788884672989 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns( + APP_VERSION_TABLE, + [ + column('sourceSizeBytes') + .int.notNull.default(0) + .comment('Size of the source tarball, in bytes'), + column('distSizeBytes').int.comment( + 'Size of the dist tarball, in bytes; null once pruned by retention', + ), + ], + { recreatesOnSqlite: true }, + ); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns(APP_VERSION_TABLE, ['sourceSizeBytes', 'distSizeBytes'], { + recreatesOnSqlite: true, + }); + } +} diff --git a/packages/@n8n/db/src/migrations/sqlite/1788884672989-AddSizeColumnsToAppVersion.ts b/packages/@n8n/db/src/migrations/sqlite/1788884672989-AddSizeColumnsToAppVersion.ts new file mode 100644 index 00000000000..4b61dc15805 --- /dev/null +++ b/packages/@n8n/db/src/migrations/sqlite/1788884672989-AddSizeColumnsToAppVersion.ts @@ -0,0 +1,5 @@ +import { AddSizeColumnsToAppVersion1788884672989 as BaseMigration } from '../common/1788884672989-AddSizeColumnsToAppVersion'; + +export class AddSizeColumnsToAppVersion1788884672989 extends BaseMigration { + withFKsDisabled = true as const; +} diff --git a/packages/cli/src/modules/apps/__tests__/app-version.service.test.ts b/packages/cli/src/modules/apps/__tests__/app-version.service.test.ts index 875c9f083e4..411bc048ae4 100644 --- a/packages/cli/src/modules/apps/__tests__/app-version.service.test.ts +++ b/packages/cli/src/modules/apps/__tests__/app-version.service.test.ts @@ -1,3 +1,4 @@ +import type { GlobalConfig } from '@n8n/config'; import type { InstanceSettings } from 'n8n-core'; import { gzipSync } from 'node:zlib'; import { Header } from 'tar'; @@ -7,6 +8,8 @@ import type { AppVersionBlobStore } from '../app-version-blob-store'; import type { AppVersionRepository } from '../app-version.repository'; import { AppVersionService } from '../app-version.service'; import type { AppRepository } from '../app.repository'; +import { AppBlobSizeQuotaExceededError } from '../errors/app-blob-size-quota-exceeded.error'; +import { AppVersionQuotaExceededError } from '../errors/app-version-quota-exceeded.error'; import { InvalidAppVersionTarballError } from '../errors/invalid-app-version-tarball.error'; const tgz = (files: Record) => { @@ -28,19 +31,29 @@ describe('AppVersionService', () => { let appRepository: ReturnType>; let appVersionRepository: ReturnType>; let blobStore: ReturnType>; + let globalConfig: GlobalConfig; let service: AppVersionService; beforeEach(() => { appRepository = mock(); appVersionRepository = mock(); blobStore = mock(); + globalConfig = mock({ + apps: { + maxVersionsPerApp: 50, + maxProjectBlobSize: 500 * 1024 * 1024, + }, + }); service = new AppVersionService( appRepository, appVersionRepository, blobStore, mock({ n8nFolder: '/tmp/n8n' }), + globalConfig, ); - appRepository.existsBy.mockResolvedValue(true); + appVersionRepository.countByAppId.mockResolvedValue(0); + appVersionRepository.sumSizeByProjectId.mockResolvedValue(0); + appVersionRepository.findDistPrunable.mockResolvedValue([]); }); describe('create', () => { @@ -50,7 +63,7 @@ describe('AppVersionService', () => { .mockResolvedValueOnce(sourceBlob) .mockRejectedValueOnce(new Error('disk full')); - await expect(service.create('app-1', source, dist)).rejects.toThrow('disk full'); + await expect(service.create('app-1', 'project-1', source, dist)).rejects.toThrow('disk full'); expect(blobStore.delete).toHaveBeenCalledWith([sourceBlob]); expect(appVersionRepository.insertVersion).not.toHaveBeenCalled(); @@ -62,7 +75,9 @@ describe('AppVersionService', () => { blobStore.write.mockResolvedValueOnce(sourceBlob).mockResolvedValueOnce(distBlob); appVersionRepository.insertVersion.mockRejectedValue(new Error('constraint')); - await expect(service.create('app-1', source, dist)).rejects.toThrow('constraint'); + await expect(service.create('app-1', 'project-1', source, dist)).rejects.toThrow( + 'constraint', + ); expect(blobStore.delete).toHaveBeenCalledWith([sourceBlob, distBlob]); }); @@ -70,7 +85,7 @@ describe('AppVersionService', () => { it('rejects a dist without index.html at its root', async () => { const noIndex = tgz({ './assets/app.js': ';', './nested/index.html': '' }); - await expect(service.create('app-1', source, noIndex)).rejects.toThrow( + await expect(service.create('app-1', 'project-1', source, noIndex)).rejects.toThrow( InvalidAppVersionTarballError, ); @@ -93,11 +108,57 @@ describe('AppVersionService', () => { gzipSync(Buffer.alloc(1024)), ]); - await expect(service.create('app-1', bomb, dist)).rejects.toThrow( + await expect(service.create('app-1', 'project-1', bomb, dist)).rejects.toThrow( /Invalid source tarball: unpacks to more than/, ); expect(blobStore.write).not.toHaveBeenCalled(); }); + + it('persists sourceSizeBytes/distSizeBytes equal to the tarball buffer lengths', async () => { + const sourceBlob = { storedAt: 'db' as const, storageKey: 'source-key' }; + const distBlob = { storedAt: 'db' as const, storageKey: 'dist-key' }; + blobStore.write.mockResolvedValueOnce(sourceBlob).mockResolvedValueOnce(distBlob); + + await service.create('app-1', 'project-1', source, dist); + + expect(appVersionRepository.insertVersion).toHaveBeenCalledWith( + expect.objectContaining({ + sourceSizeBytes: source.length, + distSizeBytes: dist.length, + }), + ); + }); + + it('throws AppVersionQuotaExceededError when the app already has maxVersionsPerApp versions, without writing any blob', async () => { + appVersionRepository.countByAppId.mockResolvedValue(50); + + await expect(service.create('app-1', 'project-1', source, dist)).rejects.toThrow( + AppVersionQuotaExceededError, + ); + + expect(blobStore.write).not.toHaveBeenCalled(); + }); + + it('throws AppBlobSizeQuotaExceededError when existing project usage plus the new tarballs would exceed maxProjectBlobSize', async () => { + appVersionRepository.sumSizeByProjectId.mockResolvedValue( + globalConfig.apps.maxProjectBlobSize - source.length, // one byte under, dist pushes it over + ); + + await expect(service.create('app-1', 'project-1', source, dist)).rejects.toThrow( + AppBlobSizeQuotaExceededError, + ); + + expect(blobStore.write).not.toHaveBeenCalled(); + }); + + it('checks quotas before parsing the tarball, rejecting for the quota reason even when the tarball itself is invalid', async () => { + appVersionRepository.countByAppId.mockResolvedValue(50); + const garbage = Buffer.from('not a tarball at all'); + + await expect(service.create('app-1', 'project-1', garbage, garbage)).rejects.toThrow( + AppVersionQuotaExceededError, + ); + }); }); }); diff --git a/packages/cli/src/modules/apps/__tests__/apps.service.test.ts b/packages/cli/src/modules/apps/__tests__/apps.service.test.ts new file mode 100644 index 00000000000..4046df9949c --- /dev/null +++ b/packages/cli/src/modules/apps/__tests__/apps.service.test.ts @@ -0,0 +1,75 @@ +import type { GlobalConfig } from '@n8n/config'; +import { mock } from 'vitest-mock-extended'; + +import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import type { AppVersionService } from '../app-version.service'; +import type { App } from '../app.entity'; +import type { AppRepository } from '../app.repository'; +import { AppNotFoundError } from '../errors/app-not-found.error'; +import { AppQuotaExceededError } from '../errors/app-quota-exceeded.error'; +import type { PageRepository } from '../page.repository'; +import { AppsService } from '../apps.service'; + +describe('AppsService', () => { + let appRepository: ReturnType>; + let appVersionService: ReturnType>; + let globalConfig: GlobalConfig; + let service: AppsService; + + beforeEach(() => { + appRepository = mock(); + appVersionService = mock(); + globalConfig = mock({ apps: { maxAppsPerProject: 20 } }); + service = new AppsService( + appRepository, + mock(), + mock(), + appVersionService, + globalConfig, + ); + }); + + describe('createApp', () => { + it('throws AppQuotaExceededError when the project already has maxAppsPerProject apps', async () => { + appRepository.countByProjectId.mockResolvedValue(20); + + await expect( + service.createApp('project-1', { name: 'App', namespace: 'app' }), + ).rejects.toThrow(AppQuotaExceededError); + + expect(appRepository.createApp).not.toHaveBeenCalled(); + }); + + it('creates the app when under quota', async () => { + appRepository.countByProjectId.mockResolvedValue(19); + + await service.createApp('project-1', { name: 'App', namespace: 'app' }); + + expect(appRepository.createApp).toHaveBeenCalledWith('project-1', 'App', 'app'); + }); + }); + + describe('createVersion', () => { + it('throws AppNotFoundError before touching appVersionService.create when the app does not exist', async () => { + appRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.createVersion('missing-app', Buffer.from(''), Buffer.from('')), + ).rejects.toThrow(AppNotFoundError); + + expect(appVersionService.create).not.toHaveBeenCalled(); + }); + + it('passes the app projectId through to appVersionService.create', async () => { + const app = mock({ id: 'app-1', projectId: 'project-1' }); + appRepository.findOneBy.mockResolvedValue(app); + const source = Buffer.from('source'); + const dist = Buffer.from('dist'); + + await service.createVersion('app-1', source, dist); + + expect(appVersionService.create).toHaveBeenCalledWith('app-1', 'project-1', source, dist); + }); + }); +}); diff --git a/packages/cli/src/modules/apps/app-version.entity.ts b/packages/cli/src/modules/apps/app-version.entity.ts index 1bc2ab49dea..6ce8b85e583 100644 --- a/packages/cli/src/modules/apps/app-version.entity.ts +++ b/packages/cli/src/modules/apps/app-version.entity.ts @@ -26,4 +26,11 @@ export class AppVersion extends WithTimestampsAndStringId { /** Null once retention pruned the dist tarball; the source tarball stays. */ @Column({ type: String, nullable: true }) distStorageKey: string | null; + + @Column({ type: Number, default: 0 }) + sourceSizeBytes: number; + + /** Null once retention pruned the dist tarball, same as {@link distStorageKey}. */ + @Column({ type: Number, nullable: true }) + distSizeBytes: number | null; } diff --git a/packages/cli/src/modules/apps/app-version.repository.ts b/packages/cli/src/modules/apps/app-version.repository.ts index 661f90b6592..cc6afd4b5eb 100644 --- a/packages/cli/src/modules/apps/app-version.repository.ts +++ b/packages/cli/src/modules/apps/app-version.repository.ts @@ -10,7 +10,16 @@ export class AppVersionRepository extends Repository { } async insertVersion( - version: Pick, + version: Pick< + AppVersion, + | 'id' + | 'appId' + | 'storedAt' + | 'sourceStorageKey' + | 'distStorageKey' + | 'sourceSizeBytes' + | 'distSizeBytes' + >, ) { return await this.save(this.create(version)); } @@ -24,6 +33,20 @@ export class AppVersionRepository extends Repository { return await this.find({ where: { appId }, order: { createdAt: 'DESC', id: 'DESC' } }); } + async countByAppId(appId: string): Promise { + return await this.countBy({ appId }); + } + + /** Sum of source+dist bytes across every version of every app in a project. */ + async sumSizeByProjectId(projectId: string): Promise { + const row = await this.createQueryBuilder('v') + .innerJoin('v.app', 'app') + .select('COALESCE(SUM(v.sourceSizeBytes), 0) + COALESCE(SUM(v.distSizeBytes), 0)', 'total') + .where('app.projectId = :projectId', { projectId }) + .getRawOne<{ total: string | null }>(); + return Number(row?.total ?? 0); + } + /** Versions that still have a dist beyond the newest `keep`, never the active one. */ async findDistPrunable(appId: string, keep: number, activeVersionId: string | null) { const withDist = await this.find({ @@ -35,7 +58,7 @@ export class AppVersionRepository extends Repository { async clearDist(ids: string[]) { if (ids.length === 0) return; - await this.update({ id: In(ids) }, { distStorageKey: null }); + await this.update({ id: In(ids) }, { distStorageKey: null, distSizeBytes: null }); } async deleteByAppId(appId: string) { diff --git a/packages/cli/src/modules/apps/app-version.service.ts b/packages/cli/src/modules/apps/app-version.service.ts index 2defd4dafda..ad2e47708cd 100644 --- a/packages/cli/src/modules/apps/app-version.service.ts +++ b/packages/cli/src/modules/apps/app-version.service.ts @@ -1,4 +1,5 @@ import type { AppVersion as AppVersionResponse } from '@n8n/api-types'; +import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { generateNanoId } from '@n8n/utils/generate-nano-id'; import { InstanceSettings } from 'n8n-core'; @@ -13,7 +14,8 @@ import type { AppVersion } from './app-version.entity'; import { AppVersionRepository } from './app-version.repository'; import type { App } from './app.entity'; import { AppRepository } from './app.repository'; -import { AppNotFoundError } from './errors/app-not-found.error'; +import { AppBlobSizeQuotaExceededError } from './errors/app-blob-size-quota-exceeded.error'; +import { AppVersionQuotaExceededError } from './errors/app-version-quota-exceeded.error'; import { InvalidAppVersionTarballError } from './errors/invalid-app-version-tarball.error'; import { createDistTarFilter, DIST_TAR_LIMITS } from './serving/dist-tar-filter'; @@ -40,15 +42,22 @@ export class AppVersionService { private readonly appVersionRepository: AppVersionRepository, private readonly blobStore: AppVersionBlobStore, private readonly instanceSettings: InstanceSettings, + private readonly globalConfig: GlobalConfig, ) {} - async create(appId: string, source: Buffer, dist: Buffer): Promise { + async create( + appId: string, + projectId: string, + source: Buffer, + dist: Buffer, + ): Promise { + await this.assertUnderQuota(appId, projectId, source, dist); + await this.assertTarball('source', source); const distEntries = await this.assertTarball('dist', dist, createDistTarFilter()); if (!distEntries.some((entry) => path.posix.normalize(entry) === 'index.html')) { throw new InvalidAppVersionTarballError('dist', 'has no index.html at its root'); } - if (!(await this.appRepository.existsBy({ id: appId }))) throw new AppNotFoundError(appId); const versionId = generateNanoId(); const version = await this.storeVersion(appId, versionId, source, dist); @@ -58,6 +67,32 @@ export class AppVersionService { return version; } + /** + * Cheapest checks first, before any tarball parsing CPU cost. The app's + * existence is guaranteed by the caller (`AppsService.createVersion` calls + * `getApp` first), so this only checks quotas. + */ + private async assertUnderQuota( + appId: string, + projectId: string, + source: Buffer, + dist: Buffer, + ): Promise { + const versionCount = await this.appVersionRepository.countByAppId(appId); + if (versionCount >= this.globalConfig.apps.maxVersionsPerApp) { + throw new AppVersionQuotaExceededError( + this.globalConfig.apps.maxVersionsPerApp, + versionCount, + ); + } + + const projectSize = await this.appVersionRepository.sumSizeByProjectId(projectId); + const newSize = projectSize + source.length + dist.length; + if (newSize > this.globalConfig.apps.maxProjectBlobSize) { + throw new AppBlobSizeQuotaExceededError(this.globalConfig.apps.maxProjectBlobSize, newSize); + } + } + /** Writes both blobs and the row; a failure at any step deletes the blobs written so far. */ private async storeVersion( appId: string, @@ -77,6 +112,8 @@ export class AppVersionService { storedAt: sourceBlob.storedAt, sourceStorageKey: sourceBlob.storageKey, distStorageKey: distBlob.storageKey, + sourceSizeBytes: source.length, + distSizeBytes: dist.length, }); } catch (error) { await this.blobStore.delete(written); diff --git a/packages/cli/src/modules/apps/app.repository.ts b/packages/cli/src/modules/apps/app.repository.ts index 67d3f39d976..c3c9a035407 100644 --- a/packages/cli/src/modules/apps/app.repository.ts +++ b/packages/cli/src/modules/apps/app.repository.ts @@ -28,6 +28,10 @@ export class AppRepository extends Repository { return await this.findBy({ projectId }); } + async countByProjectId(projectId: string): Promise { + return await this.countBy({ projectId }); + } + async updateApp(app: App, updates: Partial>) { if ( updates.namespace !== undefined && diff --git a/packages/cli/src/modules/apps/apps.service.ts b/packages/cli/src/modules/apps/apps.service.ts index a1b6c072899..3f61bf2a05d 100644 --- a/packages/cli/src/modules/apps/apps.service.ts +++ b/packages/cli/src/modules/apps/apps.service.ts @@ -1,4 +1,5 @@ import type { CreateAppDto, CreatePageDto, UpdateAppDto, UpdatePageDto } from '@n8n/api-types'; +import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -7,6 +8,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { AppVersionService } from './app-version.service'; import { AppRepository } from './app.repository'; import { AppNotFoundError } from './errors/app-not-found.error'; +import { AppQuotaExceededError } from './errors/app-quota-exceeded.error'; import { DataWorkflowNotFoundError } from './errors/data-workflow-not-found.error'; import { IndexPageCannotHaveChildrenError } from './errors/index-page-cannot-have-children.error'; import { IndexPageMustBeTopLevelError } from './errors/index-page-must-be-top-level.error'; @@ -21,9 +23,14 @@ export class AppsService { private readonly pageRepository: PageRepository, private readonly workflowFinderService: WorkflowFinderService, private readonly appVersionService: AppVersionService, + private readonly globalConfig: GlobalConfig, ) {} async createApp(projectId: string, dto: CreateAppDto) { + const count = await this.appRepository.countByProjectId(projectId); + if (count >= this.globalConfig.apps.maxAppsPerProject) { + throw new AppQuotaExceededError(this.globalConfig.apps.maxAppsPerProject, count); + } return await this.appRepository.createApp(projectId, dto.name, dto.namespace); } @@ -49,7 +56,8 @@ export class AppsService { } async createVersion(appId: string, source: Buffer, dist: Buffer) { - const version = await this.appVersionService.create(appId, source, dist); + const app = await this.getApp(appId); + const version = await this.appVersionService.create(appId, app.projectId, source, dist); return this.appVersionService.toResponse(version); } diff --git a/packages/cli/src/modules/apps/errors/app-blob-size-quota-exceeded.error.ts b/packages/cli/src/modules/apps/errors/app-blob-size-quota-exceeded.error.ts new file mode 100644 index 00000000000..f0b80daf190 --- /dev/null +++ b/packages/cli/src/modules/apps/errors/app-blob-size-quota-exceeded.error.ts @@ -0,0 +1,11 @@ +import { toMb } from '@n8n/utils/number/bytes'; +import { UserError } from 'n8n-workflow'; + +export class AppBlobSizeQuotaExceededError extends UserError { + constructor(limit: number, current: number) { + super( + `App storage limit exceeded: this project's apps use ${toMb(current)}MB, limit is ${toMb(limit)}MB. Delete an old app or version to free up space.`, + { level: 'warning' }, + ); + } +} diff --git a/packages/cli/src/modules/apps/errors/app-quota-exceeded.error.ts b/packages/cli/src/modules/apps/errors/app-quota-exceeded.error.ts new file mode 100644 index 00000000000..1713370298d --- /dev/null +++ b/packages/cli/src/modules/apps/errors/app-quota-exceeded.error.ts @@ -0,0 +1,10 @@ +import { UserError } from 'n8n-workflow'; + +export class AppQuotaExceededError extends UserError { + constructor(limit: number, current: number) { + super( + `App limit exceeded: this project has ${current} apps, limit is ${limit}. Delete an app or contact your admin to raise the limit.`, + { level: 'warning' }, + ); + } +} diff --git a/packages/cli/src/modules/apps/errors/app-version-quota-exceeded.error.ts b/packages/cli/src/modules/apps/errors/app-version-quota-exceeded.error.ts new file mode 100644 index 00000000000..e76a1deba55 --- /dev/null +++ b/packages/cli/src/modules/apps/errors/app-version-quota-exceeded.error.ts @@ -0,0 +1,10 @@ +import { UserError } from 'n8n-workflow'; + +export class AppVersionQuotaExceededError extends UserError { + constructor(limit: number, current: number) { + super( + `Version limit exceeded: this app has ${current} versions, limit is ${limit}. Delete an old version to publish a new one.`, + { level: 'warning' }, + ); + } +} diff --git a/packages/cli/test/integration/apps/app-version.integration.test.ts b/packages/cli/test/integration/apps/app-version.integration.test.ts index 635f46a7cd6..36408838985 100644 --- a/packages/cli/test/integration/apps/app-version.integration.test.ts +++ b/packages/cli/test/integration/apps/app-version.integration.test.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ import { getPersonalProject, testDb } from '@n8n/backend-test-utils'; +import { AppsConfig } from '@n8n/config'; import { BinaryDataRepository, type Project, type User } from '@n8n/db'; import { Container } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; @@ -215,6 +216,73 @@ describe('POST /projects/:projectId/apps/:appId/versions', () => { await upload(app.id, sourceTgz(), Buffer.alloc(MAX_TARBALL_BYTES + 1)).expect(400); }); + test('rejects an upload past maxVersionsPerApp with a friendly message', async () => { + const app = await createApp(); + const appsConfig = Container.get(AppsConfig); + const original = appsConfig.maxVersionsPerApp; + appsConfig.maxVersionsPerApp = 1; + + try { + await upload(app.id).expect(200); + + const response = await upload(app.id).expect(400); + + expect(response.body.message).toContain('Version limit exceeded'); + expect(await appVersionRepository.listByAppId(app.id)).toHaveLength(1); + } finally { + appsConfig.maxVersionsPerApp = original; + } + }); + + test('rejects an upload that would exceed maxProjectBlobSize with a friendly message', async () => { + const app = await createApp(); + const appsConfig = Container.get(AppsConfig); + const original = appsConfig.maxProjectBlobSize; + appsConfig.maxProjectBlobSize = 10; + + try { + const response = await upload(app.id).expect(400); + + expect(response.body.message).toContain('App storage limit exceeded'); + expect(await appVersionRepository.listByAppId(app.id)).toHaveLength(0); + } finally { + appsConfig.maxProjectBlobSize = original; + } + }); + + test("sumSizeByProjectId only counts the given project's apps, and excludes pruned dist blobs", async () => { + const memberProject = await getPersonalProject(member); + const app = await createApp(); + const otherApp = await appRepository.createApp(memberProject.id, 'Other', 'other'); + + await upload(app.id).expect(200); + await authMemberAgent + .post(`/projects/${memberProject.id}/apps/${otherApp.id}/versions`) + .attach('source', sourceTgz(), 'src.tgz') + .attach('dist', distTgz(), 'dist.tgz') + .expect(200); + + const [version] = await appVersionRepository.listByAppId(app.id); + expect(await appVersionRepository.sumSizeByProjectId(ownerProject.id)).toBe( + version.sourceSizeBytes + (version.distSizeBytes ?? 0), + ); + expect(await appVersionRepository.sumSizeByProjectId(memberProject.id)).toBeGreaterThan(0); + + // Pruning (six uploads keeps five dists) clears distSizeBytes alongside + // distStorageKey - the sum must equal exactly what the surviving rows hold. + // Which of the six gets pruned isn't fixed (ties on createdAt), so read it + // back from the rows rather than assuming it's the first upload. + for (let i = 0; i < 5; i++) await upload(app.id, sourceTgz(), distTgz(`v${i}`)).expect(200); + const versions = await appVersionRepository.listByAppId(app.id); + expect(versions).toHaveLength(6); + expect(versions.filter((v) => v.distSizeBytes === null)).toHaveLength(1); + const expectedTotal = versions.reduce( + (sum, v) => sum + v.sourceSizeBytes + (v.distSizeBytes ?? 0), + 0, + ); + expect(await appVersionRepository.sumSizeByProjectId(ownerProject.id)).toBe(expectedTotal); + }); + test('skips entries that would land outside the dist directory', async () => { const app = await createApp(); const evil = tgz([ diff --git a/packages/cli/test/integration/apps/apps.controller.integration.test.ts b/packages/cli/test/integration/apps/apps.controller.integration.test.ts index 45c56dcdb45..ca29dc34ac2 100644 --- a/packages/cli/test/integration/apps/apps.controller.integration.test.ts +++ b/packages/cli/test/integration/apps/apps.controller.integration.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { createWorkflow, getPersonalProject, testDb } from '@n8n/backend-test-utils'; +import { AppsConfig } from '@n8n/config'; import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -58,6 +59,29 @@ describe('POST /projects/:projectId/apps', () => { .expect(403); }); + test('rejects creating an app past maxAppsPerProject with a friendly message', async () => { + const appsConfig = Container.get(AppsConfig); + const original = appsConfig.maxAppsPerProject; + appsConfig.maxAppsPerProject = 1; + + try { + await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps`) + .send({ name: 'First', namespace: 'first' }) + .expect(200); + + const response = await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps`) + .send({ name: 'Second', namespace: 'second' }) + .expect(400); + + expect(response.body.message).toContain('App limit exceeded'); + expect(await appRepository.countByProjectId(ownerProject.id)).toBe(1); + } finally { + appsConfig.maxAppsPerProject = original; + } + }); + test('rejects a duplicate namespace in the same project with 409', async () => { await appRepository.createApp(ownerProject.id, 'First', 'dup');