feat: app-version-quota-limits (#38139)

This commit is contained in:
Toni Devine 2026-09-09 12:04:06 +01:00 committed by GitHub
commit 3ba3ce4056
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 434 additions and 15 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
@ -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

View File

@ -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;
}

View File

@ -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;

View File

@ -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,

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
import { AddSizeColumnsToAppVersion1788884672989 as BaseMigration } from '../common/1788884672989-AddSizeColumnsToAppVersion';
export class AddSizeColumnsToAppVersion1788884672989 extends BaseMigration {
withFKsDisabled = true as const;
}

View File

@ -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<string, string>) => {
@ -28,19 +31,29 @@ describe('AppVersionService', () => {
let appRepository: ReturnType<typeof mock<AppRepository>>;
let appVersionRepository: ReturnType<typeof mock<AppVersionRepository>>;
let blobStore: ReturnType<typeof mock<AppVersionBlobStore>>;
let globalConfig: GlobalConfig;
let service: AppVersionService;
beforeEach(() => {
appRepository = mock<AppRepository>();
appVersionRepository = mock<AppVersionRepository>();
blobStore = mock<AppVersionBlobStore>();
globalConfig = mock<GlobalConfig>({
apps: {
maxVersionsPerApp: 50,
maxProjectBlobSize: 500 * 1024 * 1024,
},
});
service = new AppVersionService(
appRepository,
appVersionRepository,
blobStore,
mock<InstanceSettings>({ 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,
);
});
});
});

View File

@ -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<typeof mock<AppRepository>>;
let appVersionService: ReturnType<typeof mock<AppVersionService>>;
let globalConfig: GlobalConfig;
let service: AppsService;
beforeEach(() => {
appRepository = mock<AppRepository>();
appVersionService = mock<AppVersionService>();
globalConfig = mock<GlobalConfig>({ apps: { maxAppsPerProject: 20 } });
service = new AppsService(
appRepository,
mock<PageRepository>(),
mock<WorkflowFinderService>(),
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<App>({ 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);
});
});
});

View File

@ -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;
}

View File

@ -10,7 +10,16 @@ export class AppVersionRepository extends Repository<AppVersion> {
}
async insertVersion(
version: Pick<AppVersion, 'id' | 'appId' | 'storedAt' | 'sourceStorageKey' | 'distStorageKey'>,
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<AppVersion> {
return await this.find({ where: { appId }, order: { createdAt: 'DESC', id: 'DESC' } });
}
async countByAppId(appId: string): Promise<number> {
return await this.countBy({ appId });
}
/** Sum of source+dist bytes across every version of every app in a project. */
async sumSizeByProjectId(projectId: string): Promise<number> {
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<AppVersion> {
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) {

View File

@ -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<AppVersion> {
async create(
appId: string,
projectId: string,
source: Buffer,
dist: Buffer,
): Promise<AppVersion> {
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<void> {
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);

View File

@ -28,6 +28,10 @@ export class AppRepository extends Repository<App> {
return await this.findBy({ projectId });
}
async countByProjectId(projectId: string): Promise<number> {
return await this.countBy({ projectId });
}
async updateApp(app: App, updates: Partial<Pick<App, 'name' | 'namespace' | 'theme'>>) {
if (
updates.namespace !== undefined &&

View File

@ -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);
}

View File

@ -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' },
);
}
}

View File

@ -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' },
);
}
}

View File

@ -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' },
);
}
}

View File

@ -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([

View File

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