diff --git a/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts b/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts index fae5d31d85..de05756711 100644 --- a/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts +++ b/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts @@ -58,6 +58,42 @@ test.describe('Postcard card front component', () => { } }); + // The front component renders in a separate iframe + Web Worker, so its + // failures never reach the assertion output. Surface browser console output, + // uncaught errors, failed requests, and 4xx/5xx responses (e.g. a presigned + // S3 403) in the test log so CI failures are diagnosable without the trace. + test.beforeEach(({ page }) => { + page.on('console', (message) => { + console.log(`[browser:${message.type()}] ${message.text()}`); + }); + + page.on('pageerror', (error) => { + console.log(`[pageerror] ${error.message}\n${error.stack ?? ''}`); + }); + + page.on('requestfailed', (request) => { + console.log( + `[requestfailed] ${request.method()} ${request.url()} — ${ + request.failure()?.errorText ?? 'unknown error' + }`, + ); + }); + + page.on('response', (response) => { + if (response.status() >= 400) { + console.log( + `[response ${response.status()}] ${response + .request() + .method()} ${response.url()}`, + ); + } + }); + + page.on('worker', (worker) => { + console.log(`[worker started] ${worker.url()}`); + }); + }); + test('renders the postcard name and status badge in the record preview', async ({ page, }) => { diff --git a/packages/twenty-apps/examples/postcard/playwright.config.ts b/packages/twenty-apps/examples/postcard/playwright.config.ts index e74fc22876..69d0fd8984 100644 --- a/packages/twenty-apps/examples/postcard/playwright.config.ts +++ b/packages/twenty-apps/examples/postcard/playwright.config.ts @@ -1,6 +1,14 @@ import { defineConfig, devices } from '@playwright/test'; import * as path from 'path'; +// WebKit on Linux ignores Playwright's `timezoneId` context emulation for +// Intl.resolvedOptions(), so it falls back to the runner's OS zone — the legacy +// `CET` alias, which WebKit's ICU rejects. `formatInTimeZone` then throws and +// crashes the record page before the front component can render. Pinning TZ +// here forces a WebKit-supported IANA zone at the OS/ICU level (the layer +// WebKit actually reads); the browser process Playwright spawns inherits it. +process.env.TZ = 'Europe/Paris'; + // Front-end base URL of the running Twenty instance under test. const FRONT_BASE_URL = process.env.FRONT_BASE_URL ?? 'http://localhost:3001'; @@ -19,6 +27,10 @@ export default defineConfig({ screenshot: 'only-on-failure', headless: true, testIdAttribute: 'data-testid', + // Browser-level timezone emulation. Honored by Chromium; WebKit/Linux + // ignores it for Intl.resolvedOptions() (see the TZ pin above, which is the + // actual fix there). Kept aligned with TZ for deterministic date rendering. + timezoneId: 'Europe/Paris', }, expect: { timeout: 15_000, @@ -36,5 +48,13 @@ export default defineConfig({ }, dependencies: ['setup'], }, + { + name: 'webkit', + use: { + ...devices['Desktop Safari'], + storageState: path.resolve(__dirname, 'e2e/.auth/user.json'), + }, + dependencies: ['setup'], + }, ], }); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts index 2e02fc71ce..1d2cc526ca 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts @@ -15,6 +15,7 @@ import { isDefined } from 'twenty-shared/utils'; import { installStyleBridge } from '@/polyfills/installStyleBridge'; import { installStylePropertyOnRemoteElements } from '@/remote/utils/installStylePropertyOnRemoteElements'; import { patchRemoteElementAttributes } from '@/remote/utils/patchRemoteElementAttributes'; +import { fetchComponentSource } from './utils/fetchComponentSource'; import { installErrorEventBridge } from './utils/installErrorEventBridge'; import { type FrontComponentExecutionContext } from 'twenty-sdk/front-component'; import { frontComponentHostCommunicationApi } from '@/constants/frontComponentHostCommunicationApi'; @@ -38,21 +39,6 @@ exposeGlobals({ __HTML_TAG_TO_CUSTOM_ELEMENT_TAG__: HTML_TAG_TO_CUSTOM_ELEMENT_TAG, }); -const fetchComponentSource = async ( - url: string, - headers?: Record, -): Promise => { - const response = await fetch(url, { headers }); - - if (!response.ok) { - throw new Error( - `Failed to fetch ${url}: ${response.status} ${response.statusText}`, - ); - } - - return response.text(); -}; - const SDK_IMPORT_SPECIFIERS = [ 'twenty-client-sdk/core', 'twenty-client-sdk/metadata', @@ -122,10 +108,10 @@ const render: WorkerExports['render'] = async ( ? { Authorization: `Bearer ${renderContext.applicationAccessToken}` } : undefined; - const componentSource = await fetchComponentSource( - renderContext.componentUrl, - authHeaders, - ); + const componentSource = await fetchComponentSource({ + url: renderContext.componentUrl, + headers: authHeaders, + }); const hasSdkImports = isDefined(renderContext.sdkClientUrls) && diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSource.spec.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSource.spec.ts new file mode 100644 index 0000000000..ff3ca7e9c2 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSource.spec.ts @@ -0,0 +1,263 @@ +import { createHash, webcrypto } from 'node:crypto'; +import { TextEncoder as NodeTextEncoder } from 'node:util'; + +import { fetchComponentSource } from '@/remote/worker/utils/fetchComponentSource'; + +const COMPONENT_SOURCE = 'export default () => {};'; + +const computeSha256Hex = (content: string): string => + createHash('sha256').update(content).digest('hex'); + +const buildFingerprintedUrl = (checksum: string): string => + `https://api.twenty.com/rest/front-components/component-id/${checksum}.js`; + +const FINGERPRINTED_URL = buildFingerprintedUrl( + computeSha256Hex(COMPONENT_SOURCE), +); +const BARE_URL = 'https://api.twenty.com/rest/front-components/component-id'; +const LEGACY_MD5_URL = buildFingerprintedUrl( + createHash('md5').update(COMPONENT_SOURCE).digest('hex'), +); + +const createFakeJsResponse = (body: string) => ({ + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: (name: string) => + name === 'content-type' ? 'application/javascript' : null, + }, + text: jest.fn(async () => body), + json: jest.fn(async () => undefined), +}); + +class FakeCache { + private readonly store = new Map(); + + match = jest.fn(async (key: string) => { + const cached = this.store.get(key); + + return isDefinedString(cached) ? { text: async () => cached } : undefined; + }); + + put = jest.fn( + async (key: string, response: { text: () => Promise }) => { + this.store.set(key, await response.text()); + }, + ); + + delete = jest.fn(async (key: string) => this.store.delete(key)); +} + +const isDefinedString = (value: string | undefined): value is string => + value !== undefined; + +const setupCaches = (cache: FakeCache) => { + (globalThis as unknown as { caches: unknown }).caches = { + open: jest.fn(async () => cache), + }; +}; + +describe('fetchComponentSource', () => { + const originalFetch = globalThis.fetch; + const originalCaches = (globalThis as unknown as { caches?: unknown }).caches; + const originalResponse = (globalThis as unknown as { Response?: unknown }) + .Response; + const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + const originalTextEncoder = ( + globalThis as unknown as { TextEncoder?: unknown } + ).TextEncoder; + + beforeEach(() => { + (globalThis as unknown as { Response: unknown }).Response = class { + body: string; + constructor(body: string) { + this.body = body; + } + async text() { + return this.body; + } + }; + + Object.defineProperty(globalThis, 'crypto', { + value: webcrypto, + configurable: true, + }); + (globalThis as unknown as { TextEncoder: unknown }).TextEncoder = + NodeTextEncoder; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + (globalThis as unknown as { caches?: unknown }).caches = originalCaches; + (globalThis as unknown as { Response?: unknown }).Response = + originalResponse; + + if (originalCrypto !== undefined) { + Object.defineProperty(globalThis, 'crypto', originalCrypto); + } + + (globalThis as unknown as { TextEncoder?: unknown }).TextEncoder = + originalTextEncoder; + jest.clearAllMocks(); + }); + + it('fetches from the network and caches the result on a miss', async () => { + const cache = new FakeCache(); + + setupCaches(cache); + + const fetchMock = jest.fn(async () => + createFakeJsResponse(COMPONENT_SOURCE), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSource({ url: FINGERPRINTED_URL }); + + expect(source).toBe(COMPONENT_SOURCE); + expect(cache.put).toHaveBeenCalledTimes(1); + expect(cache.put.mock.calls[0][0]).toBe(FINGERPRINTED_URL); + }); + + it('serves a verified cache hit without hitting the network', async () => { + const cache = new FakeCache(); + + await cache.put(FINGERPRINTED_URL, { + text: async () => COMPONENT_SOURCE, + }); + + setupCaches(cache); + + const fetchMock = jest.fn(); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSource({ url: FINGERPRINTED_URL }); + + expect(source).toBe(COMPONENT_SOURCE); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('evicts a poisoned cache entry and refetches from the network', async () => { + const cache = new FakeCache(); + + await cache.put(FINGERPRINTED_URL, { + text: async () => 'globalThis.injectedByAnotherComponent = true;', + }); + + setupCaches(cache); + + const fetchMock = jest.fn(async () => + createFakeJsResponse(COMPONENT_SOURCE), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSource({ url: FINGERPRINTED_URL }); + + expect(source).toBe(COMPONENT_SOURCE); + expect(cache.delete).toHaveBeenCalledWith(FINGERPRINTED_URL); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not cache a network response whose checksum does not match the URL', async () => { + const cache = new FakeCache(); + + setupCaches(cache); + + const staleUrl = buildFingerprintedUrl( + computeSha256Hex('some other build output'), + ); + + const fetchMock = jest.fn(async () => + createFakeJsResponse(COMPONENT_SOURCE), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSource({ url: staleUrl }); + + expect(source).toBe(COMPONENT_SOURCE); + expect(cache.put).not.toHaveBeenCalled(); + }); + + it('never touches the cache for a non-fingerprinted URL', async () => { + const cache = new FakeCache(); + + setupCaches(cache); + + const fetchMock = jest.fn(async () => + createFakeJsResponse('bare url source'), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSource({ url: BARE_URL }); + + expect(source).toBe('bare url source'); + expect(cache.match).not.toHaveBeenCalled(); + expect(cache.put).not.toHaveBeenCalled(); + }); + + it('never touches the cache for a legacy md5-fingerprinted URL', async () => { + const cache = new FakeCache(); + + setupCaches(cache); + + const fetchMock = jest.fn(async () => + createFakeJsResponse(COMPONENT_SOURCE), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSource({ url: LEGACY_MD5_URL }); + + expect(source).toBe(COMPONENT_SOURCE); + expect(cache.match).not.toHaveBeenCalled(); + expect(cache.put).not.toHaveBeenCalled(); + }); + + it('falls back to the network when CacheStorage is unavailable', async () => { + (globalThis as unknown as { caches?: unknown }).caches = undefined; + + const fetchMock = jest.fn(async () => + createFakeJsResponse(COMPONENT_SOURCE), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSource({ url: FINGERPRINTED_URL }); + + expect(source).toBe(COMPONENT_SOURCE); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not trust the cache when WebCrypto is unavailable', async () => { + const cache = new FakeCache(); + + await cache.put(FINGERPRINTED_URL, { + text: async () => COMPONENT_SOURCE, + }); + cache.put.mockClear(); + + setupCaches(cache); + + Object.defineProperty(globalThis, 'crypto', { + value: undefined, + configurable: true, + }); + + const fetchMock = jest.fn(async () => + createFakeJsResponse(COMPONENT_SOURCE), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSource({ url: FINGERPRINTED_URL }); + + expect(source).toBe(COMPONENT_SOURCE); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cache.put).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts new file mode 100644 index 0000000000..13f5012016 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts @@ -0,0 +1,135 @@ +import { fetchComponentSourceFromNetwork } from '@/remote/worker/utils/fetchComponentSourceFromNetwork'; + +const COMPONENT_URL = + 'https://api.twenty.com/rest/front-components/component-id/checksum-abc.js'; +const PRESIGNED_URL = 'https://s3.example.com/component.js?signed=abc'; + +type FakeResponseInit = { + ok?: boolean; + status?: number; + statusText?: string; + contentType?: string; + body?: string; + json?: unknown; +}; + +const createFakeResponse = ({ + ok = true, + status = 200, + statusText = 'OK', + contentType = 'application/javascript', + body = '', + json, +}: FakeResponseInit) => ({ + ok, + status, + statusText, + headers: { + get: (name: string) => (name === 'content-type' ? contentType : null), + }, + text: jest.fn(async () => body), + json: jest.fn(async () => json), +}); + +describe('fetchComponentSourceFromNetwork', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + jest.clearAllMocks(); + }); + + it('returns the raw body and forwards headers for a direct JS response', async () => { + const fetchMock = jest.fn(async () => + createFakeResponse({ body: 'export default () => {};' }), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSourceFromNetwork({ + url: COMPONENT_URL, + headers: { Authorization: 'Bearer token' }, + }); + + expect(source).toBe('export default () => {};'); + expect(fetchMock).toHaveBeenCalledWith(COMPONENT_URL, { + headers: { Authorization: 'Bearer token' }, + }); + }); + + it('follows the JSON handoff and fetches the presigned URL header-less', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce( + createFakeResponse({ + contentType: 'application/json', + json: { url: PRESIGNED_URL }, + }), + ) + .mockResolvedValueOnce( + createFakeResponse({ body: 'presigned bundle source' }), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSourceFromNetwork({ + url: COMPONENT_URL, + headers: { Authorization: 'Bearer token' }, + }); + + expect(source).toBe('presigned bundle source'); + expect(fetchMock).toHaveBeenNthCalledWith(1, COMPONENT_URL, { + headers: { Authorization: 'Bearer token' }, + }); + expect(fetchMock).toHaveBeenNthCalledWith(2, PRESIGNED_URL); + }); + + it('throws when the initial response is not ok', async () => { + const fetchMock = jest.fn(async () => + createFakeResponse({ ok: false, status: 404, statusText: 'Not Found' }), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect( + fetchComponentSourceFromNetwork({ url: COMPONENT_URL }), + ).rejects.toThrow(`Failed to fetch ${COMPONENT_URL}: 404 Not Found`); + }); + + it('throws when the JSON handoff payload is invalid', async () => { + const fetchMock = jest.fn(async () => + createFakeResponse({ + contentType: 'application/json', + json: { notUrl: 'nope' }, + }), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect( + fetchComponentSourceFromNetwork({ url: COMPONENT_URL }), + ).rejects.toThrow( + `Invalid component source handoff response from ${COMPONENT_URL}`, + ); + }); + + it('throws when the presigned response is not ok', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce( + createFakeResponse({ + contentType: 'application/json', + json: { url: PRESIGNED_URL }, + }), + ) + .mockResolvedValueOnce( + createFakeResponse({ ok: false, status: 403, statusText: 'Forbidden' }), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect( + fetchComponentSourceFromNetwork({ url: COMPONENT_URL }), + ).rejects.toThrow('Failed to fetch presigned URL: 403 Forbidden'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/extractComponentChecksumFromUrl.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/extractComponentChecksumFromUrl.ts new file mode 100644 index 0000000000..609ff88d75 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/extractComponentChecksumFromUrl.ts @@ -0,0 +1,11 @@ +const SHA256_FINGERPRINTED_URL_PATTERN = /\/([0-9a-f]{64})\.js$/; + +export const extractComponentChecksumFromUrl = ({ + url, +}: { + url: string; +}): string | undefined => { + const match = url.match(SHA256_FINGERPRINTED_URL_PATTERN); + + return match?.[1]; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSource.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSource.ts new file mode 100644 index 0000000000..a2345c11d9 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSource.ts @@ -0,0 +1,54 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { extractComponentChecksumFromUrl } from '@/remote/worker/utils/extractComponentChecksumFromUrl'; +import { fetchComponentSourceFromNetwork } from '@/remote/worker/utils/fetchComponentSourceFromNetwork'; +import { frontComponentCacheStorageService } from '@/remote/worker/utils/frontComponentCacheStorageService'; + +export const fetchComponentSource = async ({ + url, + headers, +}: { + url: string; + headers?: Record; +}): Promise => { + const expectedChecksum = extractComponentChecksumFromUrl({ url }); + + const cache = isDefined(expectedChecksum) + ? await frontComponentCacheStorageService.open() + : undefined; + + if (isDefined(cache) && isDefined(expectedChecksum)) { + const cachedSource = await frontComponentCacheStorageService.read({ + cache, + url, + }); + + if (isDefined(cachedSource)) { + const cachedSourceChecksum = + await frontComponentCacheStorageService.computeChecksum({ + source: cachedSource, + }); + + if (cachedSourceChecksum === expectedChecksum) { + return cachedSource; + } + + // Poisoned or corrupt entry: any same-origin code can write to + // CacheStorage, so a mismatch means the content cannot be trusted. + frontComponentCacheStorageService.delete({ cache, url }); + } + } + + const source = await fetchComponentSourceFromNetwork({ url, headers }); + + if (isDefined(cache) && isDefined(expectedChecksum)) { + const sourceChecksum = + await frontComponentCacheStorageService.computeChecksum({ source }); + + if (sourceChecksum === expectedChecksum) { + frontComponentCacheStorageService.write({ cache, url, source }); + } + } + + return source; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts new file mode 100644 index 0000000000..afd8ce74a0 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +const componentSourceHandoffSchema = z.object({ url: z.url() }); + +export const fetchComponentSourceFromNetwork = async ({ + url, + headers, +}: { + url: string; + headers?: Record; +}): Promise => { + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error( + `Failed to fetch ${url}: ${response.status} ${response.statusText}`, + ); + } + + const contentType = response.headers.get('content-type') ?? ''; + + if (!contentType.includes('application/json')) { + return response.text(); + } + + const handoff = componentSourceHandoffSchema.safeParse(await response.json()); + + if (!handoff.success) { + throw new Error(`Invalid component source handoff response from ${url}`); + } + + const presignedResponse = await fetch(handoff.data.url); + + if (!presignedResponse.ok) { + throw new Error( + `Failed to fetch presigned URL: ${presignedResponse.status} ${presignedResponse.statusText}`, + ); + } + + return presignedResponse.text(); +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/frontComponentCacheStorageService.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/frontComponentCacheStorageService.ts new file mode 100644 index 0000000000..8884cfb06d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/frontComponentCacheStorageService.ts @@ -0,0 +1,79 @@ +import { isDefined } from 'twenty-shared/utils'; + +const FRONT_COMPONENT_SOURCE_CACHE_NAME = 'front-component-source-v1'; + +export const frontComponentCacheStorageService = { + computeChecksum: async ({ + source, + }: { + source: string; + }): Promise => { + if (typeof crypto === 'undefined' || !isDefined(crypto.subtle)) { + return undefined; + } + + try { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(source), + ); + + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + } catch { + return undefined; + } + }, + + open: async (): Promise => { + if (typeof caches === 'undefined') { + return undefined; + } + + try { + return await caches.open(FRONT_COMPONENT_SOURCE_CACHE_NAME); + } catch { + return undefined; + } + }, + + read: async ({ + cache, + url, + }: { + cache: Cache; + url: string; + }): Promise => { + try { + const cachedResponse = await cache.match(url); + + return isDefined(cachedResponse) ? cachedResponse.text() : undefined; + } catch { + return undefined; + } + }, + + write: ({ + cache, + url, + source, + }: { + cache: Cache; + url: string; + source: string; + }): void => { + cache + .put( + url, + new Response(source, { + headers: { 'Content-Type': 'application/javascript' }, + }), + ) + .catch(() => undefined); + }, + + delete: ({ cache, url }: { cache: Cache; url: string }): void => { + cache.delete(url).catch(() => undefined); + }, +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts index 447ca5a378..8bc8448606 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts @@ -1,9 +1,9 @@ +import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface'; import crypto from 'crypto'; import type * as esbuild from 'esbuild'; import { readFile } from 'node:fs/promises'; import path from 'path'; -import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface'; -import { type FileFolder } from 'twenty-shared/types'; +import { FileFolder } from 'twenty-shared/types'; const SDK_CLIENT_IMPORT_PREFIX = 'twenty-client-sdk'; @@ -34,7 +34,13 @@ export const processEsbuildResult = async ({ const relativeSourcePath = path.relative(appPath, absoluteSourcePath); const content = await readFile(absoluteBuiltFile); - const checksum = crypto.createHash('md5').update(content).digest('hex'); + + const checksumAlgorithm = + fileFolder === FileFolder.BuiltFrontComponent ? 'sha256' : 'md5'; + const checksum = crypto + .createHash(checksumAlgorithm) + .update(content) + .digest('hex'); const lastChecksum = lastChecksums.get(relativeBuiltPath); diff --git a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts index 9df2b56095..4508ff343d 100644 --- a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts @@ -15,6 +15,11 @@ export const IMMUTABLE_FILE_CACHE_CONTROL = 'private, max-age=86400, immutable'; export const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=3600'; +// Responses embedding a short-lived presigned URL must never be stored: +// a cached copy either 403s once the signature expires or leaks a live +// presigned URL to clients that never authenticated. +export const PRESIGNED_URL_NO_STORE_CACHE_CONTROL = 'private, no-store'; + export const fileFolderConfigs: Record = { [FileFolder.CorePicture]: { ignoreExpirationToken: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts index 430ef15c29..7b76f3473d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts @@ -17,6 +17,7 @@ import { FileStorageException, FileStorageExceptionCode, } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; +import { PRESIGNED_URL_NO_STORE_CACHE_CONTROL } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { setFileResponseHeaders } from 'src/engine/core-modules/file/utils/set-file-response-headers.utils'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -85,7 +86,9 @@ export class FrontComponentController { }); if (fileResponse.type === 'redirect') { - return res.redirect(fileResponse.presignedUrl); + res.setHeader('Cache-Control', PRESIGNED_URL_NO_STORE_CACHE_CONTROL); + + return res.json({ url: fileResponse.presignedUrl }); } setFileResponseHeaders( diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts index be125dbc2d..f71937fb20 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts @@ -6,7 +6,6 @@ import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; -import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; 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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -333,9 +332,6 @@ export class FrontComponentService { ), responseContentType: mimeType, responseContentDisposition: getContentDisposition(mimeType), - responseCacheControl: - fileFolderConfigs[FileFolder.BuiltFrontComponent].cacheControl ?? - undefined, }); if (presignedUrl) { diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service.ts index b3e7fce203..2f37d69a51 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service.ts @@ -102,8 +102,10 @@ export class PrefillFrontComponentService { }, }); + // sha-256 (not md5) so the renderer can verify cached bundles against + // the URL checksum with WebCrypto, which has no md5 support const checksum = crypto - .createHash('md5') + .createHash('sha256') .update(builtFile.content) .digest('hex');