Compare commits

...
Author SHA1 Message Date
martmull 1fdbea70fd Merge branch 'main' into update-cli-version-of-fixture-apps 2026-04-13 19:54:54 +02:00
martmull 60a4360a0e Fix ci 2026-04-11 10:37:05 +02:00
martmull e78693998b Fix install application content tab 2026-04-10 22:13:30 +02:00
martmull 17bdb52249 Fix typing 2026-04-10 21:26:15 +02:00
martmull bec8bf476f Remove useless info 2026-04-10 18:17:05 +02:00
martmull 27c384cc23 Stop modifying manifest before saving 2026-04-10 18:05:42 +02:00
martmull 25d44dacdb Add icon in applicaiton lists 2026-04-10 15:11:12 +02:00
martmull 91ab778aa8 pick af7ccf83ab qdlkj
Update package
2026-04-10 14:58:13 +02:00
40 changed files with 1995 additions and 1541 deletions
@@ -14,14 +14,14 @@
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"dependencies": {
"twenty-sdk": "latest",
"twenty-client-sdk": "latest"
"twenty-client-sdk": "latest",
"twenty-sdk": "latest"
},
"devDependencies": {
"typescript": "^5.9.3",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2",
"oxlint": "^0.16.0",
"react": "^19.0.2",
"oxlint": "^0.16.0"
"typescript": "^5.9.3"
}
}
@@ -47,9 +47,9 @@ type ApplicationRegistration {
latestAvailableVersion: String
isListed: Boolean!
isFeatured: Boolean!
logoUrl: String
createdAt: DateTime!
updatedAt: DateTime!
logoUrl: String
}
enum ApplicationRegistrationSourceType {
@@ -281,13 +281,6 @@ type Role {
rowLevelPermissionPredicateGroups: [RowLevelPermissionPredicateGroup!]
}
type ApplicationRegistrationSummary {
id: UUID!
latestAvailableVersion: String
sourceType: ApplicationRegistrationSourceType!
logoUrl: String
}
type ApplicationVariable {
id: UUID!
key: String!
@@ -568,7 +561,7 @@ type Application {
logicFunctions: [LogicFunction!]!
objects: [Object!]!
applicationVariables: [ApplicationVariable!]!
applicationRegistration: ApplicationRegistrationSummary
applicationRegistration: ApplicationRegistration
}
type ViewField {
@@ -2526,6 +2519,8 @@ type MarketplaceAppDetail {
universalIdentifier: String!
id: String!
name: String!
logoUrl: String
screenshots: [String!]
sourceType: ApplicationRegistrationSourceType!
sourcePackage: String
latestAvailableVersion: String
@@ -51,9 +51,9 @@ export interface ApplicationRegistration {
latestAvailableVersion?: Scalars['String']
isListed: Scalars['Boolean']
isFeatured: Scalars['Boolean']
logoUrl?: Scalars['String']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
logoUrl?: Scalars['String']
__typename: 'ApplicationRegistration'
}
@@ -230,14 +230,6 @@ export interface Role {
__typename: 'Role'
}
export interface ApplicationRegistrationSummary {
id: Scalars['UUID']
latestAvailableVersion?: Scalars['String']
sourceType: ApplicationRegistrationSourceType
logoUrl?: Scalars['String']
__typename: 'ApplicationRegistrationSummary'
}
export interface ApplicationVariable {
id: Scalars['UUID']
key: Scalars['String']
@@ -399,7 +391,7 @@ export interface Application {
logicFunctions: LogicFunction[]
objects: Object[]
applicationVariables: ApplicationVariable[]
applicationRegistration?: ApplicationRegistrationSummary
applicationRegistration?: ApplicationRegistration
__typename: 'Application'
}
@@ -2239,6 +2231,8 @@ export interface MarketplaceAppDetail {
universalIdentifier: Scalars['String']
id: Scalars['String']
name: Scalars['String']
logoUrl?: Scalars['String']
screenshots?: Scalars['String'][]
sourceType: ApplicationRegistrationSourceType
sourcePackage?: Scalars['String']
latestAvailableVersion?: Scalars['String']
@@ -3234,9 +3228,9 @@ export interface ApplicationRegistrationGenqlSelection{
latestAvailableVersion?: boolean | number
isListed?: boolean | number
isFeatured?: boolean | number
logoUrl?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
logoUrl?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -3406,15 +3400,6 @@ export interface RoleGenqlSelection{
__scalar?: boolean | number
}
export interface ApplicationRegistrationSummaryGenqlSelection{
id?: boolean | number
latestAvailableVersion?: boolean | number
sourceType?: boolean | number
logoUrl?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApplicationVariableGenqlSelection{
id?: boolean | number
key?: boolean | number
@@ -3614,7 +3599,7 @@ export interface ApplicationGenqlSelection{
logicFunctions?: LogicFunctionGenqlSelection
objects?: ObjectGenqlSelection
applicationVariables?: ApplicationVariableGenqlSelection
applicationRegistration?: ApplicationRegistrationSummaryGenqlSelection
applicationRegistration?: ApplicationRegistrationGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -5552,6 +5537,8 @@ export interface MarketplaceAppDetailGenqlSelection{
universalIdentifier?: boolean | number
id?: boolean | number
name?: boolean | number
logoUrl?: boolean | number
screenshots?: boolean | number
sourceType?: boolean | number
sourcePackage?: boolean | number
latestAvailableVersion?: boolean | number
@@ -7016,14 +7003,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ApplicationRegistrationSummary_possibleTypes: string[] = ['ApplicationRegistrationSummary']
export const isApplicationRegistrationSummary = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationSummary => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationSummary"')
return ApplicationRegistrationSummary_possibleTypes.includes(obj.__typename)
}
const ApplicationVariable_possibleTypes: string[] = ['ApplicationVariable']
export const isApplicationVariable = (obj?: { __typename?: any } | null): obj is ApplicationVariable => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationVariable"')
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,19 +1,11 @@
import { gql } from '@apollo/client';
import { APPLICATION_FRAGMENT } from '@/applications/graphql/fragments/applicationFragment';
export const FIND_MANY_APPLICATIONS = gql`
${APPLICATION_FRAGMENT}
query FindManyApplications {
findManyApplications {
id
name
description
version
universalIdentifier
applicationRegistrationId
applicationRegistration {
id
latestAvailableVersion
sourceType
}
...ApplicationFields
}
}
`;
@@ -5,6 +5,8 @@ export const MARKETPLACE_APP_DETAIL_FRAGMENT = gql`
id
universalIdentifier
name
logoUrl
screenshots
sourceType
sourcePackage
latestAvailableVersion
@@ -9,7 +9,12 @@ import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, Status } from 'twenty-ui/display';
import {
Avatar,
H2Title,
OverflowingTextWithTooltip,
Status,
} from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
@@ -90,7 +95,21 @@ export const SettingsAdminApps = () => {
mobileGridAutoColumns={TABLE_GRID_MOBILE}
isClickable
>
<TableCell>{registration.name}</TableCell>
<TableCell
color={themeCssVariables.font.color.secondary}
gap={themeCssVariables.spacing[2]}
minWidth="0"
overflow="hidden"
>
<Avatar
avatarUrl={registration.logoUrl || null}
placeholder={registration.name}
placeholderColorSeed={registration.name}
size="md"
type="squared"
/>
<OverflowingTextWithTooltip text={registration.name} />
</TableCell>
<TableCell overflow="hidden">
{registration.sourcePackage ?? registration.sourceType}
</TableCell>
@@ -5,6 +5,7 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
id
universalIdentifier
name
logoUrl
oAuthClientId
oAuthRedirectUris
oAuthScopes
@@ -5,6 +5,7 @@ import { Avatar } from 'twenty-ui/display';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useParams } from 'react-router-dom';
type SettingsItemTypeTagProps = {
item: {
@@ -28,10 +29,20 @@ export const SettingsItemTypeTag = ({
item: { isCustom, isRemote, applicationId },
}: SettingsItemTypeTagProps) => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { applicationId: currentApplicationId = '' } = useParams<{
applicationId: string;
}>();
const itemTagInfo = getItemTagInfo({
item: { isCustom, isRemote, applicationId },
item: {
isCustom,
isRemote,
applicationId,
},
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
currentApplicationId,
});
const { theme } = useContext(ThemeContext);
@@ -39,6 +50,7 @@ export const SettingsItemTypeTag = ({
return (
<StyledContainer className={className}>
<Avatar
avatarUrl={itemTagInfo?.logoUrl}
placeholder={itemTagInfo.labelText}
placeholderColorSeed={itemTagInfo.labelText}
type="squared"
@@ -1,10 +1,18 @@
import { isDefined } from 'twenty-shared/utils';
import { type ApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
export type ItemTagInfo =
export type ItemTagInfo = (
| ThisAppItemTagInfo
| StandardItemTagInfo
| CustomItemTagInfo
| RemoteItemTagInfo
| ManagedItemTagInfo;
| ManagedItemTagInfo
) & { logoUrl?: string };
type ThisAppItemTagInfo = {
labelText: 'This app';
labelColor: 'sky';
};
type StandardItemTagInfo = {
labelText: 'Standard';
@@ -27,16 +35,26 @@ type ManagedItemTagInfo = {
};
export const getItemTagInfo = ({
item: { isCustom, isRemote, applicationId },
item: { isCustom, isRemote, applicationId, logoUrl },
workspaceCustomApplicationId,
currentApplicationId,
}: {
item: {
isCustom?: boolean;
isRemote?: boolean;
applicationId?: string | null;
};
item: ApplicationDataTableRow['tagItem'];
workspaceCustomApplicationId?: string;
currentApplicationId?: string;
}): ItemTagInfo => {
if (
isDefined(applicationId) &&
isDefined(currentApplicationId) &&
applicationId === currentApplicationId
) {
return {
labelText: 'This app',
labelColor: 'sky',
logoUrl: logoUrl ?? undefined,
};
}
if (
isDefined(applicationId) &&
applicationId !== workspaceCustomApplicationId
@@ -213,7 +213,8 @@ export const SettingsAvailableApplicationDetails = () => {
const displayName = app?.displayName ?? detail?.name ?? '';
const description = app?.description ?? '';
const screenshots = app?.screenshots ?? [];
const logoUrl = detail?.logoUrl ?? undefined;
const screenshots = detail?.screenshots ?? [];
const aboutDescription = app?.aboutDescription;
const currentVersion = application?.version;
@@ -222,7 +223,6 @@ export const SettingsAvailableApplicationDetails = () => {
const sourceType = detail?.sourceType;
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
const registrationId = detail?.id;
const sourcePackage = detail?.sourcePackage;
const sourcePackageUrl =
isNpmApp && detail?.sourcePackage
? `https://www.npmjs.com/package/${detail.sourcePackage}`
@@ -585,7 +585,7 @@ export const SettingsAvailableApplicationDetails = () => {
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
logoUrl={app?.logoUrl}
logoUrl={logoUrl}
isUnlisted={isUnlisted}
/>
}
@@ -25,6 +25,7 @@ export type ApplicationDataTableRow = {
isCustom?: boolean;
isRemote?: boolean;
applicationId?: string | null;
logoUrl?: string;
};
};
@@ -7,13 +7,24 @@ import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronRight, useIcons } from 'twenty-ui/display';
import {
IconChevronRight,
useIcons,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { type ApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
import { styled } from '@linaria/react';
const MAIN_ROW_GRID_COLUMNS = '180px 1fr 98.7px 36px';
const StyledIconWrapper = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
`;
export const SettingsApplicationDataTableRow = ({
row,
}: {
@@ -28,9 +39,11 @@ export const SettingsApplicationDataTableRow = ({
<TableRow gridAutoColumns={MAIN_ROW_GRID_COLUMNS} to={row.link}>
<StyledNameTableCell>
{isDefined(Icon) && (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
<StyledIconWrapper>
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
</StyledIconWrapper>
)}
{row.labelPlural}
<OverflowingTextWithTooltip text={row.labelPlural} />
</StyledNameTableCell>
<TableCell>
<SettingsItemTypeTag item={row.tagItem} />
@@ -3,7 +3,7 @@ import { type ReactNode } from 'react';
import { t } from '@lingui/core/macro';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Tag } from 'twenty-ui/components';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
@@ -36,6 +36,13 @@ export const SettingsApplicationTableRow = ({
minWidth="0"
overflow="hidden"
>
<Avatar
avatarUrl={application.applicationRegistration?.logoUrl || null}
placeholder={application.name}
placeholderColorSeed={application.name}
size="md"
type="squared"
/>
<OverflowingTextWithTooltip text={application.name} />
</TableCell>
<TableCell gap={themeCssVariables.spacing[2]} minWidth="0">
@@ -7,11 +7,9 @@ import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconCircleDot, IconStatusChange, IconUpload } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
ApplicationRegistrationSourceType,
type Application,
} from '~/generated-metadata/graphql';
import { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
const StyledContainer = styled.div`
display: flex;
@@ -24,9 +22,7 @@ export const SettingsApplicationVersionContainer = ({
latestAvailableVersion,
appRegistrationId,
}: {
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
objects: { id: string }[];
};
application?: ApplicationWithoutRelation;
latestAvailableVersion?: string | null;
appRegistrationId?: string | null;
}) => {
@@ -16,15 +16,14 @@ import {
} from '~/generated-metadata/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
const UNINSTALL_APPLICATION_MODAL_ID = 'uninstall-application-modal';
export const SettingsApplicationDetailAboutTab = ({
application,
}: {
application?: Omit<Application, 'objects'> & {
objects: { id: string }[];
};
application?: ApplicationWithoutRelation;
}) => {
const { openModal } = useModal();
@@ -8,19 +8,17 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { type Application } from '~/generated-metadata/graphql';
import { SettingsAIAgentsTable } from '~/pages/settings/ai/components/SettingsAIAgentsTable';
import {
SettingsApplicationDataTable,
type ApplicationDataTableRow,
} from '~/pages/settings/applications/components/SettingsApplicationDataTable';
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
export const SettingsApplicationDetailContentTab = ({
application,
}: {
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
objects: { id: string }[];
};
application?: ApplicationWithoutRelation;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
@@ -1,11 +1,15 @@
import { type Application } from '~/generated-metadata/graphql';
import {
type Application,
type ApplicationRegistration,
} from '~/generated-metadata/graphql';
export type ApplicationWithoutRelation = Pick<
export type ApplicationWithoutRelation = Omit<
Application,
| 'id'
| 'name'
| 'description'
| 'version'
| 'applicationRegistrationId'
| 'applicationRegistration'
>;
'objects' | 'applicationRegistration'
> & {
objects: { id: string }[];
applicationRegistration?: Pick<
ApplicationRegistration,
'id' | 'latestAvailableVersion' | 'sourceType' | 'logoUrl'
> | null;
};
@@ -27,7 +27,7 @@ export const getPublicAssetUrl = (path: string): string => {
);
}
const { workspaceId, applicationId } = decodeTokenPayload(token);
const { applicationId } = decodeTokenPayload(token);
const withoutLeadingSlash = path.startsWith('/') ? path.slice(1) : path;
const withPublicPrefix = withoutLeadingSlash.startsWith('public/')
? withoutLeadingSlash
@@ -38,5 +38,5 @@ export const getPublicAssetUrl = (path: string): string => {
.map(encodeURIComponent)
.join('/');
return `${apiUrl}/public-assets/${workspaceId}/${applicationId}/${encodedPath}`;
return `${apiUrl}/applications/${applicationId}/assets/${encodedPath}`;
};
@@ -0,0 +1,549 @@
// Factory-based jest.mock calls prevent the circular entity dependency chain
// (ApplicationRegistrationEntity ↔ UserEntity ↔ WorkspaceEntity) from loading.
jest.mock(
'src/engine/core-modules/application/application-registration/application-registration.service',
() => ({ ApplicationRegistrationService: jest.fn() }),
);
jest.mock('src/engine/core-modules/application/application.service', () => ({
ApplicationService: jest.fn(),
}));
jest.mock(
'src/engine/core-modules/twenty-config/twenty-config.service',
() => ({ TwentyConfigService: jest.fn() }),
);
jest.mock('src/engine/core-modules/file/services/file.service', () => ({
FileService: jest.fn(),
}));
import { type CanActivate, NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { Readable } from 'stream';
import { FileFolder } from 'twenty-shared/types';
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import {
FileException,
FileExceptionCode,
} from 'src/engine/core-modules/file/file.exception';
import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/file-api-exception.filter';
import { FileByIdGuard } from 'src/engine/core-modules/file/guards/file-by-id.guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ApplicationAssetController } from 'src/engine/core-modules/application/application-asset/application-asset.controller';
const createMockStream = (): Readable => {
const stream = new Readable();
stream.push('file content');
stream.push(null);
stream.pipe = jest.fn();
return stream;
};
const createMockResponse = () => ({
setHeader: jest.fn(),
redirect: jest.fn(),
});
describe('ApplicationAssetController', () => {
let controller: ApplicationAssetController;
let fileService: FileService;
let applicationService: ApplicationService;
let applicationRegistrationService: ApplicationRegistrationService;
let twentyConfigService: TwentyConfigService;
const mock_FileByIdGuard: CanActivate = { canActivate: jest.fn(() => true) };
const mock_PublicEndpointGuard: CanActivate = {
canActivate: jest.fn(() => true),
};
const mock_NoPermissionGuard: CanActivate = {
canActivate: jest.fn(() => true),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ApplicationAssetController],
providers: [
{
provide: FileService,
useValue: {
getFileStreamById: jest.fn(),
getFileStreamByPath: jest.fn(),
getFileResponseById: jest.fn(),
},
},
{
provide: ApplicationService,
useValue: {
findById: jest.fn(),
},
},
{
provide: ApplicationRegistrationService,
useValue: {
findOneByIdGlobal: jest.fn(),
findApplicationByRegistrationId: jest.fn(),
},
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn(),
},
},
],
})
.overrideGuard(FileByIdGuard)
.useValue(mock_FileByIdGuard)
.overrideGuard(PublicEndpointGuard)
.useValue(mock_PublicEndpointGuard)
.overrideGuard(NoPermissionGuard)
.useValue(mock_NoPermissionGuard)
.overrideFilter(FileApiExceptionFilter)
.useValue({})
.compile();
controller = module.get<ApplicationAssetController>(
ApplicationAssetController,
);
fileService = module.get<FileService>(FileService);
applicationService = module.get<ApplicationService>(ApplicationService);
applicationRegistrationService = module.get<ApplicationRegistrationService>(
ApplicationRegistrationService,
);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('getPublicAssets', () => {
it('should call fileService.getFileStreamByPath and pipe with headers', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
stream: mockStream,
mimeType: 'image/png',
});
const mockRequest = {
params: { path: ['images', 'logo.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await controller.getPublicAssets(
mockResponse,
mockRequest,
'workspace-id',
'app-id',
);
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
workspaceId: 'workspace-id',
applicationId: 'app-id',
fileFolder: FileFolder.PublicAsset,
filepath: 'images/logo.png',
});
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'image/png',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'X-Content-Type-Options',
'nosniff',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'inline',
);
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
});
it('should handle single-segment path', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
stream: mockStream,
mimeType: 'image/x-icon',
});
const mockRequest = {
params: { path: ['favicon.ico'] },
} as any;
const mockResponse = createMockResponse() as any;
await controller.getPublicAssets(
mockResponse,
mockRequest,
'workspace-id',
'app-id',
);
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
workspaceId: 'workspace-id',
applicationId: 'app-id',
fileFolder: FileFolder.PublicAsset,
filepath: 'favicon.ico',
});
});
it('should throw FileException with FILE_NOT_FOUND when asset is not found', async () => {
jest
.spyOn(fileService, 'getFileStreamByPath')
.mockRejectedValue(
new FileStorageException(
'File not found',
FileStorageExceptionCode.FILE_NOT_FOUND,
),
);
const mockRequest = {
params: { path: ['missing-asset.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getPublicAssets(
mockResponse,
mockRequest,
'workspace-id',
'app-id',
),
).rejects.toThrow(
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
);
});
it('should throw FileException with INTERNAL_SERVER_ERROR for unexpected errors', async () => {
jest
.spyOn(fileService, 'getFileStreamByPath')
.mockRejectedValue(new Error('Connection refused'));
const mockRequest = {
params: { path: ['broken-asset.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getPublicAssets(
mockResponse,
mockRequest,
'workspace-id',
'app-id',
),
).rejects.toThrow(
new FileException(
'Error retrieving file: Connection refused',
FileExceptionCode.INTERNAL_SERVER_ERROR,
),
);
});
});
describe('getApplicationAsset', () => {
it('should look up application and stream file with headers', async () => {
const mockStream = createMockStream();
jest.spyOn(applicationService, 'findById').mockResolvedValue({
id: 'app-id',
workspaceId: 'workspace-id',
} as any);
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
stream: mockStream,
mimeType: 'application/javascript',
});
const mockRequest = {
params: { path: ['js', 'main.js'] },
} as any;
const mockResponse = createMockResponse() as any;
await controller.getApplicationAsset(mockResponse, mockRequest, 'app-id');
expect(applicationService.findById).toHaveBeenCalledWith('app-id');
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
workspaceId: 'workspace-id',
applicationId: 'app-id',
fileFolder: FileFolder.PublicAsset,
filepath: 'js/main.js',
});
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'application/javascript',
);
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
});
it('should throw NotFoundException when application is not found', async () => {
jest.spyOn(applicationService, 'findById').mockResolvedValue(null);
const mockRequest = {
params: { path: ['logo.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getApplicationAsset(mockResponse, mockRequest, 'unknown-id'),
).rejects.toThrow(NotFoundException);
});
it('should throw FileException with FILE_NOT_FOUND when asset is not found', async () => {
jest.spyOn(applicationService, 'findById').mockResolvedValue({
id: 'app-id',
workspaceId: 'workspace-id',
} as any);
jest
.spyOn(fileService, 'getFileStreamByPath')
.mockRejectedValue(
new FileStorageException(
'File not found',
FileStorageExceptionCode.FILE_NOT_FOUND,
),
);
const mockRequest = {
params: { path: ['missing.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getApplicationAsset(mockResponse, mockRequest, 'app-id'),
).rejects.toThrow(
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
);
});
it('should throw FileException with INTERNAL_SERVER_ERROR for unexpected errors', async () => {
jest.spyOn(applicationService, 'findById').mockResolvedValue({
id: 'app-id',
workspaceId: 'workspace-id',
} as any);
jest
.spyOn(fileService, 'getFileStreamByPath')
.mockRejectedValue(new Error('Disk failure'));
const mockRequest = {
params: { path: ['broken.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getApplicationAsset(mockResponse, mockRequest, 'app-id'),
).rejects.toThrow(
new FileException(
'Error retrieving file: Disk failure',
FileExceptionCode.INTERNAL_SERVER_ERROR,
),
);
});
});
describe('getApplicationRegistrationAsset', () => {
it('should redirect to CDN for NPM source type', async () => {
jest
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
.mockResolvedValue({
id: 'reg-id',
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: '@twenty/my-app',
latestAvailableVersion: '1.2.3',
} as any);
jest
.spyOn(twentyConfigService, 'get')
.mockReturnValue('https://cdn.example.com');
const mockRequest = {
params: { path: ['assets', 'icon.svg'] },
} as any;
const mockResponse = createMockResponse() as any;
await controller.getApplicationRegistrationAsset(
mockResponse,
mockRequest,
'reg-id',
);
expect(mockResponse.redirect).toHaveBeenCalledWith(
'https://cdn.example.com/@twenty/my-app@1.2.3/assets/icon.svg',
);
});
it('should stream file for TARBALL source type with ownerWorkspaceId', async () => {
const mockStream = createMockStream();
jest
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
.mockResolvedValue({
id: 'reg-id',
sourceType: ApplicationRegistrationSourceType.TARBALL,
ownerWorkspaceId: 'owner-ws-id',
} as any);
jest
.spyOn(
applicationRegistrationService,
'findApplicationByRegistrationId',
)
.mockResolvedValue({
id: 'installed-app-id',
} as any);
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
stream: mockStream,
mimeType: 'text/css',
});
const mockRequest = {
params: { path: ['styles', 'main.css'] },
} as any;
const mockResponse = createMockResponse() as any;
await controller.getApplicationRegistrationAsset(
mockResponse,
mockRequest,
'reg-id',
);
expect(
applicationRegistrationService.findApplicationByRegistrationId,
).toHaveBeenCalledWith('reg-id', 'owner-ws-id');
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
workspaceId: 'owner-ws-id',
applicationId: 'installed-app-id',
fileFolder: FileFolder.PublicAsset,
filepath: 'styles/main.css',
});
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/css',
);
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
});
it('should stream file for LOCAL source type with ownerWorkspaceId', async () => {
const mockStream = createMockStream();
jest
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
.mockResolvedValue({
id: 'reg-id',
sourceType: ApplicationRegistrationSourceType.LOCAL,
ownerWorkspaceId: 'owner-ws-id',
} as any);
jest
.spyOn(
applicationRegistrationService,
'findApplicationByRegistrationId',
)
.mockResolvedValue({
id: 'local-app-id',
} as any);
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
stream: mockStream,
mimeType: 'image/png',
});
const mockRequest = {
params: { path: ['icon.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await controller.getApplicationRegistrationAsset(
mockResponse,
mockRequest,
'reg-id',
);
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
workspaceId: 'owner-ws-id',
applicationId: 'local-app-id',
fileFolder: FileFolder.PublicAsset,
filepath: 'icon.png',
});
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
});
it('should throw NotFoundException when no installed application found for TARBALL registration', async () => {
jest
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
.mockResolvedValue({
id: 'reg-id',
sourceType: ApplicationRegistrationSourceType.TARBALL,
ownerWorkspaceId: 'owner-ws-id',
} as any);
jest
.spyOn(
applicationRegistrationService,
'findApplicationByRegistrationId',
)
.mockResolvedValue(null);
const mockRequest = {
params: { path: ['asset.js'] },
} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getApplicationRegistrationAsset(
mockResponse,
mockRequest,
'reg-id',
),
).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException for unsupported source type', async () => {
jest
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
.mockResolvedValue({
id: 'reg-id',
sourceType: ApplicationRegistrationSourceType.OAUTH_ONLY,
} as any);
const mockRequest = {
params: { path: ['asset.js'] },
} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getApplicationRegistrationAsset(
mockResponse,
mockRequest,
'reg-id',
),
).rejects.toThrow(NotFoundException);
});
});
});
@@ -0,0 +1,212 @@
import {
Controller,
Get,
NotFoundException,
Param,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { join } from 'path';
import { Request, Response } from 'express';
import { FileFolder } from 'twenty-shared/types';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { setFileResponseHeaders } from 'src/engine/core-modules/file/utils/set-file-response-headers.utils';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
FileException,
FileExceptionCode,
} from 'src/engine/core-modules/file/file.exception';
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
@Controller()
export class ApplicationAssetController {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationService: ApplicationService,
private readonly fileService: FileService,
private readonly twentyConfigService: TwentyConfigService,
) {}
// @deprecated Use GET /applications/:applicationId/assets/*path instead.
// Kept for backwards compatibility with older SDK versions.
@Get('public-assets/:workspaceId/:applicationId/*path')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getPublicAssets(
@Res() res: Response,
@Req() req: Request,
@Param('workspaceId') workspaceId: string,
@Param('applicationId')
applicationId: string,
) {
const filepath = join(...req.params.path);
try {
const { stream, mimeType } = await this.fileService.getFileStreamByPath({
workspaceId,
applicationId,
fileFolder: FileFolder.PublicAsset,
filepath,
});
setFileResponseHeaders(res, mimeType);
stream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
});
stream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
throw new FileException(
'File not found',
FileExceptionCode.FILE_NOT_FOUND,
);
}
throw new FileException(
`Error retrieving file: ${error.message}`,
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
}
}
@Get('applications/:applicationId/assets/*path')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getApplicationAsset(
@Res() res: Response,
@Req() req: Request,
@Param('applicationId')
applicationId: string,
) {
const filepath = join(...req.params.path);
const application = await this.applicationService.findById(applicationId);
if (!application) {
throw new NotFoundException(
`Application with id '${applicationId}' not found`,
);
}
try {
const { stream, mimeType } = await this.fileService.getFileStreamByPath({
workspaceId: application.workspaceId,
applicationId,
fileFolder: FileFolder.PublicAsset,
filepath,
});
setFileResponseHeaders(res, mimeType);
stream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
});
stream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
throw new FileException(
'File not found',
FileExceptionCode.FILE_NOT_FOUND,
);
}
throw new FileException(
`Error retrieving file: ${error.message}`,
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
}
}
@Get('application-registrations/:applicationRegistrationId/assets/*path')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getApplicationRegistrationAsset(
@Res() res: Response,
@Req() req: Request,
@Param('applicationRegistrationId') applicationRegistrationId: string,
) {
const filepath = join(...req.params.path);
const registration =
await this.applicationRegistrationService.findOneByIdGlobal(
applicationRegistrationId,
);
if (
registration.sourceType === ApplicationRegistrationSourceType.NPM &&
registration.sourcePackage &&
registration.latestAvailableVersion
) {
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
const cdnUrl = buildRegistryCdnUrl({
cdnBaseUrl,
packageName: registration.sourcePackage,
version: registration.latestAvailableVersion,
filePath: filepath,
});
return res.redirect(cdnUrl);
}
if (
(registration.sourceType === ApplicationRegistrationSourceType.TARBALL ||
registration.sourceType === ApplicationRegistrationSourceType.LOCAL) &&
registration.ownerWorkspaceId
) {
const application =
await this.applicationRegistrationService.findApplicationByRegistrationId(
registration.id,
registration.ownerWorkspaceId,
);
if (!application) {
throw new NotFoundException(
`No installed application found for registration ${applicationRegistrationId}`,
);
}
const { stream, mimeType } = await this.fileService.getFileStreamByPath({
workspaceId: registration.ownerWorkspaceId,
applicationId: application.id,
fileFolder: FileFolder.PublicAsset,
filepath,
});
setFileResponseHeaders(res, mimeType);
stream.pipe(res);
return;
}
throw new NotFoundException(
`Public asset not available for registration ${applicationRegistrationId}`,
);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ApplicationAssetController } from 'src/engine/core-modules/application/application-asset/application-asset.controller';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
@Module({
imports: [ApplicationRegistrationModule, ApplicationModule, FileModule],
controllers: [ApplicationAssetController],
})
export class ApplicationAssetModule {}
@@ -24,7 +24,6 @@ import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import {
@@ -37,7 +36,6 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
@@ -66,7 +64,6 @@ export class ApplicationDevelopmentResolver {
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly fileStorageService: FileStorageService,
private readonly sdkClientGenerationService: SdkClientGenerationService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@Mutation(() => DevelopmentApplicationDTO)
@@ -160,12 +157,7 @@ export class ApplicationDevelopmentResolver {
});
}
await this.syncRegistrationMetadata(
applicationRegistrationId,
manifest,
workspaceId,
application.id,
);
await this.syncRegistrationMetadata(applicationRegistrationId, manifest);
return {
applicationUniversalIdentifier:
@@ -236,20 +228,10 @@ export class ApplicationDevelopmentResolver {
private async syncRegistrationMetadata(
applicationRegistrationId: string,
manifest: ApplicationInput['manifest'],
workspaceId: string,
applicationId: string,
): Promise<void> {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
);
await this.applicationRegistrationService.updateFromManifest(
applicationRegistrationId,
manifestWithResolvedUrls,
manifest,
);
if (manifest.application.serverVariables) {
@@ -1,6 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { type Manifest } from 'twenty-shared/application';
@@ -23,6 +29,16 @@ export class MarketplaceAppDetailDTO {
@Field()
name: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
logoUrl?: string;
@IsArray()
@Field(() => [String], { nullable: true })
@IsOptional()
screenshots?: string[];
@Field(() => ApplicationRegistrationSourceType)
sourceType: ApplicationRegistrationSourceType;
@@ -3,9 +3,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { MARKETPLACE_CURATED_APPLICATIONS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-curated-applications.constant';
@Injectable()
@@ -15,7 +12,6 @@ export class MarketplaceCatalogSyncService {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly marketplaceService: MarketplaceService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async syncCatalog(): Promise<void> {
@@ -68,19 +64,6 @@ export class MarketplaceCatalogSyncService {
}
: fetchedManifest;
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
buildRegistryCdnUrl({
cdnBaseUrl,
packageName: pkg.name,
version: pkg.version,
filePath,
}),
);
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier,
name: manifest.application.displayName ?? pkg.name,
@@ -89,7 +72,7 @@ export class MarketplaceCatalogSyncService {
latestAvailableVersion: pkg.version ?? null,
isListed: true,
isFeatured,
manifest: manifestWithResolvedUrls,
manifest,
ownerWorkspaceId: null,
});
} catch (error) {
@@ -90,7 +90,11 @@ export class MarketplaceQueryService {
icon: app?.icon ?? 'IconApps',
author: app?.author ?? 'Unknown',
category: app?.category ?? '',
logo: app?.logoUrl ?? undefined,
logo:
this.applicationRegistrationService.resolvePublicAssetUrl(
registration.id,
app?.logoUrl,
) ?? undefined,
sourcePackage: registration.sourcePackage ?? undefined,
isFeatured: registration.isFeatured,
};
@@ -99,16 +103,31 @@ export class MarketplaceQueryService {
private toMarketplaceAppDetailDTO(
registration: ApplicationRegistrationEntity,
): MarketplaceAppDetailDTO {
const manifest = registration.manifest;
return {
id: registration.id,
universalIdentifier: registration.universalIdentifier,
name: registration.name,
logoUrl: isDefined(manifest)
? (this.applicationRegistrationService.resolvePublicAssetUrl(
registration.id,
manifest.application.logoUrl,
) ?? undefined)
: undefined,
screenshots: (manifest?.application.screenshots ?? []).map(
(url) =>
this.applicationRegistrationService.resolvePublicAssetUrl(
registration.id,
url,
) ?? url,
),
sourceType: registration.sourceType,
sourcePackage: registration.sourcePackage ?? undefined,
latestAvailableVersion: registration.latestAvailableVersion ?? undefined,
isListed: registration.isListed,
isFeatured: registration.isFeatured,
manifest: registration.manifest ?? undefined,
manifest: manifest ?? undefined,
};
}
}
@@ -1,145 +0,0 @@
import { type Manifest } from 'twenty-shared/application';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
const buildMinimalManifest = (
overrides: Partial<Manifest['application']> = {},
): Manifest => ({
application: {
universalIdentifier: 'app-1',
defaultRoleUniversalIdentifier: 'role-1',
displayName: 'Test App',
description: 'A test app',
packageJsonChecksum: null,
yarnLockChecksum: null,
...overrides,
},
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
roles: [],
skills: [],
agents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
});
describe('resolveManifestAssetUrls', () => {
const urlBuilder = (filePath: string) =>
`https://cdn.example.com/pkg/${filePath}`;
it('should resolve a relative logoUrl', () => {
const manifest = buildMinimalManifest({ logoUrl: 'logo.png' });
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBe(
'https://cdn.example.com/pkg/logo.png',
);
});
it('should not modify an absolute logoUrl', () => {
const manifest = buildMinimalManifest({
logoUrl: 'https://example.com/logo.png',
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBe('https://example.com/logo.png');
});
it('should not modify an http logoUrl', () => {
const manifest = buildMinimalManifest({
logoUrl: 'http://example.com/logo.png',
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBe('http://example.com/logo.png');
});
it('should leave logoUrl undefined when not set', () => {
const manifest = buildMinimalManifest();
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBeUndefined();
});
it('should resolve relative screenshot paths', () => {
const manifest = buildMinimalManifest({
screenshots: ['screen1.png', 'screen2.png'],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([
'https://cdn.example.com/pkg/screen1.png',
'https://cdn.example.com/pkg/screen2.png',
]);
});
it('should not modify absolute screenshot URLs', () => {
const manifest = buildMinimalManifest({
screenshots: [
'https://example.com/screen1.png',
'https://example.com/screen2.png',
],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([
'https://example.com/screen1.png',
'https://example.com/screen2.png',
]);
});
it('should handle a mix of relative and absolute screenshot URLs', () => {
const manifest = buildMinimalManifest({
screenshots: ['relative.png', 'https://example.com/absolute.png'],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([
'https://cdn.example.com/pkg/relative.png',
'https://example.com/absolute.png',
]);
});
it('should handle empty screenshots array', () => {
const manifest = buildMinimalManifest({ screenshots: [] });
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([]);
});
it('should handle undefined screenshots', () => {
const manifest = buildMinimalManifest();
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([]);
});
it('should preserve all other manifest properties', () => {
const manifest = buildMinimalManifest({
logoUrl: 'logo.png',
author: 'Test Author',
websiteUrl: 'https://test.com',
});
manifest.objects = [{ universalIdentifier: 'obj-1' } as never];
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.author).toBe('Test Author');
expect(result.application.websiteUrl).toBe('https://test.com');
expect(result.application.displayName).toBe('Test App');
expect(result.objects).toEqual([{ universalIdentifier: 'obj-1' }]);
});
});
@@ -1,23 +0,0 @@
import { type Manifest } from 'twenty-shared/application';
const isAbsoluteUrl = (url: string): boolean =>
url.startsWith('http://') || url.startsWith('https://');
export const resolveManifestAssetUrls = (
manifest: Manifest,
urlBuilder: (filePath: string) => string,
): Manifest => {
const resolveUrl = (url: string): string =>
isAbsoluteUrl(url) ? url : urlBuilder(url);
return {
...manifest,
application: {
...manifest.application,
logoUrl: manifest.application.logoUrl
? resolveUrl(manifest.application.logoUrl)
: undefined,
screenshots: (manifest.application.screenshots ?? []).map(resolveUrl),
},
};
};
@@ -127,11 +127,6 @@ export class ApplicationRegistrationEntity {
@Column({ type: 'jsonb', nullable: true })
manifest: Manifest | null;
@Field(() => String, { nullable: true })
get logoUrl(): string | null {
return this.manifest?.application?.logoUrl ?? null;
}
@OneToMany(
() => ApplicationRegistrationVariableEntity,
(variable) => variable.applicationRegistration,
@@ -1,5 +1,5 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
@@ -48,7 +48,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@MetadataResolver(() => ApplicationRegistrationEntity)
@UseFilters(
ApplicationRegistrationExceptionFilter,
AuthGraphqlApiExceptionFilter,
@@ -60,7 +60,6 @@ export class ApplicationRegistrationResolver {
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationTarballService: ApplicationTarballService,
private readonly fileUrlService: FileUrlService,
private readonly domainServerConfigService: DomainServerConfigService,
) {}
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@@ -311,4 +310,14 @@ export class ApplicationRegistrationResolver {
currentOwnerWorkspaceId: workspaceId,
});
}
@ResolveField(() => String, { nullable: true })
logoUrl(
@Parent() registration: ApplicationRegistrationEntity,
): string | null {
return this.applicationRegistrationService.resolvePublicAssetUrl(
registration.id,
registration.manifest?.application?.logoUrl,
);
}
}
@@ -22,7 +22,9 @@ import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/a
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { buildApplicationRegistrationPublicAssetUrl } from 'src/engine/core-modules/application/application-registration/utils/build-application-registration-public-asset-url.util';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
const BCRYPT_SALT_ROUNDS = 10;
@@ -35,8 +37,33 @@ export class ApplicationRegistrationService {
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly twentyConfigService: TwentyConfigService,
) {}
resolvePublicAssetUrl(
applicationRegistrationId: string,
assetPath: string | undefined | null,
): string | null {
if (!assetPath) {
return null;
}
return buildApplicationRegistrationPublicAssetUrl({
serverUrl: this.twentyConfigService.get('SERVER_URL'),
applicationRegistrationId,
assetPath,
});
}
async findApplicationByRegistrationId(
applicationRegistrationId: string,
workspaceId: string,
): Promise<ApplicationEntity | null> {
return this.applicationRepository.findOne({
where: { applicationRegistrationId, workspaceId },
});
}
async findMany(
ownerWorkspaceId: string,
): Promise<ApplicationRegistrationEntity[]> {
@@ -113,7 +140,10 @@ export class ApplicationRegistrationService {
return {
id: registration.id,
name: registration.name,
logoUrl: registration.manifest?.application?.logoUrl ?? null,
logoUrl: this.resolvePublicAssetUrl(
registration.id,
registration.manifest?.application?.logoUrl,
),
websiteUrl: registration.manifest?.application?.websiteUrl ?? null,
oAuthScopes: registration.oAuthScopes,
};
@@ -1,25 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
@ObjectType('ApplicationRegistrationSummary')
export class ApplicationRegistrationSummaryDTO {
@Field(() => UUIDScalarType)
id: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
latestAvailableVersion?: string;
@Field(() => ApplicationRegistrationSourceType)
sourceType: ApplicationRegistrationSourceType;
@IsOptional()
@IsString()
@Field(() => String, { nullable: true })
logoUrl?: string | null;
}
@@ -0,0 +1,14 @@
const isAbsoluteUrl = (url: string): boolean =>
url.startsWith('http://') || url.startsWith('https://');
export const buildApplicationRegistrationPublicAssetUrl = (params: {
serverUrl: string;
applicationRegistrationId: string;
assetPath: string;
}): string => {
if (isAbsoluteUrl(params.assetPath)) {
return params.assetPath;
}
return `${params.serverUrl}/application-registrations/${params.applicationRegistrationId}/assets/${params.assetPath}`;
};
@@ -30,24 +30,6 @@ export class ApplicationService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
async findApplicationRoleId(
applicationId: string,
workspaceId: string,
): Promise<string> {
const application = await this.applicationRepository.findOne({
where: { id: applicationId, workspaceId },
});
if (!isDefined(application) || !isDefined(application.defaultRoleId)) {
throw new ApplicationException(
`Could not find application ${applicationId}`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
return application.defaultRoleId;
}
async findWorkspaceTwentyStandardAndCustomApplicationOrThrow({
workspace: workspaceInput,
workspaceId,
@@ -10,12 +10,12 @@ import {
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationSummaryDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-summary.dto';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/application/application-variable/dtos/application-variable.dto';
import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
import { LogicFunctionDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
@ObjectType('Application')
export class ApplicationDTO {
@@ -101,6 +101,6 @@ export class ApplicationDTO {
applicationVariables?: ApplicationVariableEntityDTO[];
@IsOptional()
@Field(() => ApplicationRegistrationSummaryDTO, { nullable: true })
applicationRegistration?: ApplicationRegistrationSummaryDTO;
@Field(() => ApplicationRegistrationEntity, { nullable: true })
applicationRegistration?: ApplicationRegistrationEntity;
}
@@ -77,6 +77,7 @@ import { AuditModule } from './audit/audit.module';
import { ClientConfigModule } from './client-config/client-config.module';
import { EventLogsModule } from './event-logs/event-logs.module';
import { FileModule } from './file/file.module';
import { ApplicationAssetModule } from 'src/engine/core-modules/application/application-asset/application-asset.module';
@Module({
imports: [
@@ -99,6 +100,7 @@ import { FileModule } from './file/file.module';
ApplicationInstallModule,
ApplicationUpgradeModule,
ApplicationDevelopmentModule,
ApplicationAssetModule,
MarketplaceModule,
AppTokenModule,
TimelineMessagingModule,
@@ -219,131 +219,4 @@ describe('FileController', () => {
);
});
});
describe('getPublicAssets', () => {
it('should call fileService.getFileStreamByPath and pipe with headers', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
stream: mockStream,
mimeType: 'image/png',
});
const mockRequest = {
params: { path: ['images', 'logo.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await controller.getPublicAssets(
mockResponse,
mockRequest,
'workspace-id',
'app-id',
);
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
workspaceId: 'workspace-id',
applicationId: 'app-id',
fileFolder: FileFolder.PublicAsset,
filepath: 'images/logo.png',
});
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'image/png',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'X-Content-Type-Options',
'nosniff',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'inline',
);
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
});
it('should handle single-segment path', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
stream: mockStream,
mimeType: 'image/x-icon',
});
const mockRequest = {
params: { path: ['favicon.ico'] },
} as any;
const mockResponse = createMockResponse() as any;
await controller.getPublicAssets(
mockResponse,
mockRequest,
'workspace-id',
'app-id',
);
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
workspaceId: 'workspace-id',
applicationId: 'app-id',
fileFolder: FileFolder.PublicAsset,
filepath: 'favicon.ico',
});
});
it('should throw FileException with FILE_NOT_FOUND when asset is not found', async () => {
jest
.spyOn(fileService, 'getFileStreamByPath')
.mockRejectedValue(
new FileStorageException(
'File not found',
FileStorageExceptionCode.FILE_NOT_FOUND,
),
);
const mockRequest = {
params: { path: ['missing-asset.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getPublicAssets(
mockResponse,
mockRequest,
'workspace-id',
'app-id',
),
).rejects.toThrow(
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
);
});
it('should throw FileException with INTERNAL_SERVER_ERROR for unexpected errors', async () => {
jest
.spyOn(fileService, 'getFileStreamByPath')
.mockRejectedValue(new Error('Connection refused'));
const mockRequest = {
params: { path: ['broken-asset.png'] },
} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getPublicAssets(
mockResponse,
mockRequest,
'workspace-id',
'app-id',
),
).rejects.toThrow(
new FileException(
'Error retrieving file: Connection refused',
FileExceptionCode.INTERNAL_SERVER_ERROR,
),
);
});
});
});
@@ -37,53 +37,6 @@ import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
export class FileController {
constructor(private readonly fileService: FileService) {}
@Get('public-assets/:workspaceId/:applicationId/*path')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getPublicAssets(
@Res() res: Response,
@Req() req: Request,
@Param('workspaceId') workspaceId: string,
@Param('applicationId')
applicationId: string,
) {
const filepath = join(...req.params.path);
try {
const { stream, mimeType } = await this.fileService.getFileStreamByPath({
workspaceId,
applicationId,
fileFolder: FileFolder.PublicAsset,
filepath,
});
setFileResponseHeaders(res, mimeType);
stream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
});
stream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
throw new FileException(
'File not found',
FileExceptionCode.FILE_NOT_FOUND,
);
}
throw new FileException(
`Error retrieving file: ${error.message}`,
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
}
}
@Get('file/:fileFolder/:id')
@UseGuards(FileByIdGuard, NoPermissionGuard)
async getFileById(
@@ -12,14 +12,12 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { type FileResponse } from 'src/engine/core-modules/file/types/file-response.type';
import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-content-disposition.utils';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
export class FileService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly fileStorageService: FileStorageService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(FileEntity)