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');