Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code ece30df76e fix: include yarn.lock in buildFileList for app install file uploads
https://sonarly.com/issue/33547?type=bug

When a third-party application is installed via the marketplace, the `buildFileList` method in `ApplicationInstallService` does not include `yarn.lock` in the files uploaded to S3, causing the yarn install Lambda to run without a lockfile and resolve all dependencies from scratch, which exceeds the 1024MB memory limit.

Fix: Added `yarn.lock` to the `buildFileList` method in `ApplicationInstallService`.

When commit `f3e0c12ce6a` ("Fix app install file upload #18593") refactored the file upload logic from a dynamic pattern-matching approach to an explicit manifest-based list, it accidentally dropped `yarn.lock` from the files uploaded to S3 during third-party app installation. The original code had:

```typescript
const FILE_FOLDER_MAPPING: Record<string, FileFolder> = {
  'package.json': FileFolder.Dependencies,
  'yarn.lock': FileFolder.Dependencies,  // ← This was lost
};
```

The refactored code only included `package.json`:

```typescript
files.push(
  { relativePath: 'package.json', fileFolder: FileFolder.Dependencies },
  { relativePath: 'manifest.json', fileFolder: FileFolder.Source },
);
```

The fix adds `yarn.lock` back to the list:

```typescript
files.push(
  { relativePath: 'package.json', fileFolder: FileFolder.Dependencies },
  { relativePath: 'yarn.lock', fileFolder: FileFolder.Dependencies },
  { relativePath: 'manifest.json', fileFolder: FileFolder.Source },
);
```

Without this, when a logic function from an installed app is executed, the Lambda driver's `copyDependenciesInMemory` finds no `yarn.lock` in S3, falls back to an empty lockfile stub, and the yarn install Lambda must resolve all dependencies from scratch — exceeding the 1024MB memory limit and crashing with `Runtime.OutOfMemory`.

**Note:** Workspaces that already have installed apps with missing `yarn.lock` files will need to re-install those apps for the fix to take effect. The `copyDependenciesInMemory` fallback (empty lockfile stub) will continue to be hit for already-installed apps until they are re-installed or upgraded.
2026-05-02 16:16:58 +00:00
6 changed files with 6 additions and 82 deletions
@@ -482,6 +482,7 @@ export class ApplicationInstallService {
files.push(
{ relativePath: 'package.json', fileFolder: FileFolder.Dependencies },
{ relativePath: 'yarn.lock', fileFolder: FileFolder.Dependencies },
{ relativePath: 'manifest.json', fileFolder: FileFolder.Source },
);
@@ -6,26 +6,19 @@ import {
DnsManagerException,
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
import {
BaseGraphQLError,
ConflictError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(DnsManagerException)
export class DnsManagerExceptionFilter implements ExceptionFilter {
catch(exception: DnsManagerException) {
switch (exception.code) {
case DnsManagerExceptionCode.HOSTNAME_NOT_REGISTERED:
case DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL:
throw new NotFoundError(exception);
case DnsManagerExceptionCode.INTERNAL_SERVER_ERROR:
case DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED:
throw new ConflictError(exception);
case DnsManagerExceptionCode.HOSTNAME_NOT_REGISTERED:
case DnsManagerExceptionCode.INVALID_INPUT_DATA:
case DnsManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED:
case DnsManagerExceptionCode.MULTIPLE_HOSTNAMES_FOUND:
case DnsManagerExceptionCode.INTERNAL_SERVER_ERROR:
throw new BaseGraphQLError(exception);
case DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL:
throw exception;
default: {
assertUnreachable(exception.code);
}
@@ -288,63 +288,6 @@ describe('DnsManagerService', () => {
});
});
describe('refreshHostname', () => {
it('should throw DnsManagerException when hostname is not found in Cloudflare', async () => {
const hostname = 'example.com';
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [] }),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(dnsManagerService as any).cloudflareClient = cloudflareMock;
await expect(
dnsManagerService.refreshHostname(hostname),
).rejects.toThrow(DnsManagerException);
});
it('should refresh and return hostname records when hostname exists', async () => {
const hostname = 'example.com';
const mockResult = {
id: 'custom-id',
hostname,
verification_errors: [],
ssl: {
dcv_delegation_records: [],
},
};
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [mockResult] }),
edit: jest.fn().mockResolvedValueOnce({}),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
jest
.spyOn(domainServerConfigService, 'getBaseUrl')
.mockReturnValue(new URL('https://front.domain'));
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result = await dnsManagerService.refreshHostname(hostname);
expect(result).toEqual({
id: 'custom-id',
domain: hostname,
records: expect.any(Array),
});
expect(cloudflareMock.customHostnames.edit).toHaveBeenCalledWith(
'custom-id',
{
zone_id: 'test-zone-id',
ssl: expect.any(Object),
},
);
});
});
describe('updateHostname', () => {
it('should update a custom domain and register a new one', async () => {
const fromHostname = 'old.com';
@@ -138,16 +138,7 @@ export class DnsManagerService {
options,
);
assertIsDefinedOrThrow(
publicDomainWithRecords,
new DnsManagerException(
'Hostname not found in Cloudflare',
DnsManagerExceptionCode.HOSTNAME_NOT_REGISTERED,
{
userFriendlyMessage: msg`Domain is not registered in Cloudflare`,
},
),
);
assertIsDefinedOrThrow(publicDomainWithRecords);
await this.cloudflareClient.customHostnames.edit(
publicDomainWithRecords.id,
@@ -8,7 +8,6 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -32,7 +31,6 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
)
@UsePipes(ResolverValidationPipe)
@UseFilters(
DnsManagerExceptionFilter,
PublicDomainExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
@@ -23,7 +23,6 @@ import { BillingEntitlementDTO } from 'src/engine/core-modules/billing/dtos/bill
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
@@ -83,7 +82,6 @@ const OriginHeader = createParamDecorator(
@MetadataResolver(() => WorkspaceEntity)
@UsePipes(ResolverValidationPipe)
@UseFilters(
DnsManagerExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
PermissionsGraphqlApiExceptionFilter,
)