Address #22827 review comments and converge application file endpoints (#22868)

Follow-up to #22827, addressing the review comments left around merge
time and applying the endpoint convergence discussed afterwards.

## Review comments from #22827

- **Swallowed error in dev sync asset read**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571513265)):
the swallow is intentional (a missing public asset must not fail the
whole dev sync) but it now logs a warning with the asset path and error,
and the registration keeps its previously stored file for that path
instead of losing it.
- **`isAbsoluteUrl` location**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571524234)):
moved to `twenty-shared/utils/url`. The server, and now also
`twenty-sdk`'s `normalize-application-assets`, use the shared util.
- **Soft delete vs file cleanup**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571589558)):
per review, deleting a registration is now a hard delete. Stored assets
(bytes + rows) are deleted with it, dependent rows are removed by their
existing FK cascades, and installed applications keep working with their
registration link nulled. No soft-delete/cron mechanism.
- **Asset cap too generous**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571595745)):
lowered to 10MB per review and documented in the publishing and
public-assets docs pages.
- **One missing image retriggers a full asset sync**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571646243)):
`storeRegistrationAssets` now takes `skipAlreadyStoredPaths`; the
catalog sync passes it when the package version is unchanged, so only
assets missing a stored file are fetched instead of re-downloading
everything.
- **`existing.logo` already contains the new logo**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571667847)):
correct, `updateFromManifest` runs first, so the previous "keep fileId
when the path did not change" guard compared the new logo against
itself. The fileId preservation is now keyed on the stored server file
for the exact path (files are unique per `(applicationRegistrationId,
path)`): a changed logo path no longer inherits the old file's id, and a
transient download failure on an unchanged path still keeps the working
file. This also removed the fileId-preservation bookkeeping from
`storeRegistrationAssets`.

## Endpoint convergence

- **Path-addressed public route for registration assets**: `GET
/file/server/application-registration/:fileId` is replaced by `GET
/files/application-registrations/:registrationId/*path`, mirroring the
manifest's public-folder paths and leaving room for a future `:version`
segment. Assets stay addressable by stable ids server-side; the fileId
now only marks a path as stored. No URL is ever persisted (all are built
at query time), and the old route never shipped in a release, so there
is nothing to migrate.
- **`Application.logoUrl` resolved server-side**: new `ResolveField` on
the `Application` type builds the `/public-assets/...` display URL (or
passes absolute URLs through). `useApplicationChipData` now reads it
from `currentWorkspace.installedApplications`, and the frontend
`buildApplicationLogoUrl` util is deleted, so clients no longer
construct file URLs themselves.

## Validation

- Unit: `file.controller.spec` (route renamed, traversal case added),
`server-file-storage.service.spec` (`findServerFile`,
`deleteByApplicationRegistrationId`),
`application-registration-asset-url.service.spec` (new URL shape,
url-encoding), new `isAbsoluteUrl` test; all application/file suites
pass.
- Live against a local server: new route serves tarball and rehosted npm
assets with `public, max-age=3600` (nested paths included), 404s on
missing files, unknown registrations, traversal attempts, and the
removed old route; `findManyApplicationRegistrations` returns
path-addressed URLs for stored assets, CDN fallback for npm, absolute
passthrough; `installedApplications.logoUrl` resolves the public-assets
URL and stays null for logo-less apps. Registration hard delete verified
against the DB: file rows cascade, application rows keep a nulled
registration link.
- Typecheck + lint on twenty-server, twenty-front, twenty-shared,
twenty-sdk; metadata codegen and client-sdk regenerated.
This commit is contained in:
martmull
2026-07-15 16:12:28 +02:00
committed by GitHub
parent 907c5cc39f
commit 0dbae2eda3
29 changed files with 364 additions and 306 deletions
@@ -74,7 +74,7 @@ describe('FileController', () => {
{
provide: ServerFileStorageService,
useValue: {
readServerFileById: jest.fn(),
readServerFile: jest.fn(),
},
},
],
@@ -322,28 +322,31 @@ describe('FileController', () => {
});
});
describe('getApplicationRegistrationFileById', () => {
describe('getApplicationRegistrationAsset', () => {
const createAssetRequest = (path: string[] = ['images', 'logo.png']) =>
({ params: { path } }) as any;
it('should stream the file with public cache headers', async () => {
const mockStream = createMockStream();
jest
.spyOn(serverFileStorageService, 'readServerFileById')
.mockResolvedValue({
stream: mockStream,
mimeType: 'image/png',
});
jest.spyOn(serverFileStorageService, 'readServerFile').mockResolvedValue({
stream: mockStream,
mimeType: 'image/png',
});
const mockResponse = createMockResponse() as any;
await controller.getApplicationRegistrationFileById(
await controller.getApplicationRegistrationAsset(
mockResponse,
'file-123',
createAssetRequest(),
'registration-id',
);
expect(serverFileStorageService.readServerFileById).toHaveBeenCalledWith(
'file-123',
ServerFileFolder.ApplicationRegistration,
);
expect(serverFileStorageService.readServerFile).toHaveBeenCalledWith({
fileFolder: ServerFileFolder.ApplicationRegistration,
applicationRegistrationId: 'registration-id',
resourcePath: 'images/logo.png',
});
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'image/png',
@@ -357,10 +360,10 @@ describe('FileController', () => {
it('should throw FILE_NOT_FOUND when the file does not exist', async () => {
jest
.spyOn(serverFileStorageService, 'readServerFileById')
.spyOn(serverFileStorageService, 'readServerFile')
.mockRejectedValue(
new FileStorageException(
'Server file unknown-id not found',
'Server file not found',
FileStorageExceptionCode.FILE_NOT_FOUND,
),
);
@@ -368,9 +371,35 @@ describe('FileController', () => {
const mockResponse = createMockResponse() as any;
await expect(
controller.getApplicationRegistrationFileById(
controller.getApplicationRegistrationAsset(
mockResponse,
'unknown-id',
createAssetRequest(['missing.png']),
'registration-id',
),
).rejects.toThrow(
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
);
expect(mockPipeline).not.toHaveBeenCalled();
});
it('should throw FILE_NOT_FOUND when the path is rejected by validation', async () => {
jest
.spyOn(serverFileStorageService, 'readServerFile')
.mockRejectedValue(
new FileStorageException(
'Invalid file path',
FileStorageExceptionCode.ACCESS_DENIED,
),
);
const mockResponse = createMockResponse() as any;
await expect(
controller.getApplicationRegistrationAsset(
mockResponse,
createAssetRequest(['..', 'escape.png']),
'registration-id',
),
).rejects.toThrow(
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
@@ -380,19 +409,21 @@ describe('FileController', () => {
});
it('should throw INTERNAL_SERVER_ERROR when the stream errors before headers are sent', async () => {
jest
.spyOn(serverFileStorageService, 'readServerFileById')
.mockResolvedValue({
stream: createMockStream(),
mimeType: 'image/png',
});
jest.spyOn(serverFileStorageService, 'readServerFile').mockResolvedValue({
stream: createMockStream(),
mimeType: 'image/png',
});
mockPipeline.mockRejectedValue(new Error('source backend exploded'));
const mockResponse = createMockResponse({ headersSent: false }) as any;
await expect(
controller.getApplicationRegistrationFileById(mockResponse, 'file-123'),
controller.getApplicationRegistrationAsset(
mockResponse,
createAssetRequest(),
'registration-id',
),
).rejects.toThrow(
new FileException(
'Error streaming file from storage',
@@ -404,20 +435,19 @@ describe('FileController', () => {
});
it('should destroy the response without throwing when the stream errors after headers are sent', async () => {
jest
.spyOn(serverFileStorageService, 'readServerFileById')
.mockResolvedValue({
stream: createMockStream(),
mimeType: 'image/png',
});
jest.spyOn(serverFileStorageService, 'readServerFile').mockResolvedValue({
stream: createMockStream(),
mimeType: 'image/png',
});
mockPipeline.mockRejectedValue(new Error('socket reset mid-flight'));
const mockResponse = createMockResponse({ headersSent: true }) as any;
await controller.getApplicationRegistrationFileById(
await controller.getApplicationRegistrationAsset(
mockResponse,
'file-123',
createAssetRequest(),
'registration-id',
);
expect(mockResponse.destroy).toHaveBeenCalledTimes(1);
@@ -16,7 +16,6 @@ import { type Readable } from 'stream';
import { Request, Response } from 'express';
import { FileFolder, ServerFileFolder } from 'twenty-shared/types';
import { SERVER_FILE_STORAGE_PREFIX } from 'src/engine/core-modules/file-storage/constants/server-file-storage-prefix.constant';
import {
FileStorageException,
FileStorageExceptionCode,
@@ -48,27 +47,32 @@ export class FileController {
private readonly serverFileStorageService: ServerFileStorageService,
) {}
// Serves application registration assets (logo, gallery images). These are
// instance-global marketplace resources, also displayed on the public OAuth
// authorize page, hence no auth token. The /server/ segment separates
// instance-global server files from the workspace-scoped /file/:folder/:id.
@Get(`file/${SERVER_FILE_STORAGE_PREFIX}/application-registration/:id`)
// Serves application registration assets (logo, gallery images) by their
// public folder path. These are instance-global marketplace resources, also
// displayed on the public OAuth authorize page, hence no auth token, unlike
// the workspace-scoped /file/:folder/:id.
@Get('files/application-registrations/:applicationRegistrationId/*path')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getApplicationRegistrationFileById(
async getApplicationRegistrationAsset(
@Res() res: Response,
@Param('id') fileId: string,
@Req() req: Request,
@Param('applicationRegistrationId') applicationRegistrationId: string,
) {
const filepath = join(...req.params.path);
let fileResponse: { stream: Readable; mimeType: string };
try {
fileResponse = await this.serverFileStorageService.readServerFileById(
fileId,
ServerFileFolder.ApplicationRegistration,
);
fileResponse = await this.serverFileStorageService.readServerFile({
fileFolder: ServerFileFolder.ApplicationRegistration,
applicationRegistrationId,
resourcePath: filepath,
});
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
(error.code === FileStorageExceptionCode.FILE_NOT_FOUND ||
error.code === FileStorageExceptionCode.ACCESS_DENIED)
) {
throw new FileException(
'File not found',
@@ -76,7 +80,7 @@ export class FileController {
);
}
this.logger.error('readServerFileById failed unexpectedly', { error });
this.logger.error('readServerFile failed unexpectedly', { error });
throw new FileException(
'Error retrieving file',