From 8d2287322b5d8156f002b51e77aa06d83f70ea91 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 16 Aug 2026 23:28:28 +0300 Subject: [PATCH 01/20] fix(security): stop serving and restoring deleted bundle cache Deleted versions stayed downloadable from the files edge cache and could be written back to R2. Purge that cache on delete and 404 cache hits for deleted or marked bundles. Co-authored-by: Cursor --- cloudflare_workers/files/index.ts | 12 +- .../_backend/files/file_read_cache.ts | 169 ++++++++++++++++++ supabase/functions/_backend/files/files.ts | 42 ++--- .../_backend/triggers/on_version_update.ts | 4 + tests/files-app-read-guard.unit.test.ts | 11 +- tests/files-bandwidth.unit.test.ts | 16 +- tests/files-deleted-cache.unit.test.ts | 152 ++++++++++++++++ tests/files-r2-error.test.ts | 5 +- tests/on-version-update-cleanup.unit.test.ts | 7 + 9 files changed, 383 insertions(+), 35 deletions(-) create mode 100644 supabase/functions/_backend/files/file_read_cache.ts create mode 100644 tests/files-deleted-cache.unit.test.ts diff --git a/cloudflare_workers/files/index.ts b/cloudflare_workers/files/index.ts index c32af980e4..af591a1669 100644 --- a/cloudflare_workers/files/index.ts +++ b/cloudflare_workers/files/index.ts @@ -1,4 +1,5 @@ import { WorkerEntrypoint } from 'cloudflare:workers' +import { FILE_READ_TRACKING_QUERY_PARAMS, getAttachmentFileIdFromReadPath, hasDeletedFileMarker } from '../../supabase/functions/_backend/files/file_read_cache.ts' import { app as files } from '../../supabase/functions/_backend/files/files.ts' import { handlePreviewRequest, isPreviewSubdomain } from '../../supabase/functions/_backend/files/preview.ts' import { app as download_link } from '../../supabase/functions/_backend/private/download_link.ts' @@ -11,7 +12,6 @@ export { AttachmentUploadHandler, UploadHandler } from '../../supabase/functions const functionName = 'files' const app = createHono(functionName, version) -const TRACKING_QUERY_PARAMS = ['device_id'] as const type CachedFilesLoopback = { fetch: (request: Request, init?: { cf?: { cacheKey: string } }) => Promise @@ -66,7 +66,7 @@ function isCacheablePreviewRead(request: Request): boolean { function buildWorkersCacheKey(request: Request): string | null { const url = new URL(request.url) if (isCacheableAttachmentRead(request)) - return `/files-cache${url.pathname}${normalizeSearch(url, TRACKING_QUERY_PARAMS)}` + return `/files-cache${url.pathname}${normalizeSearch(url, FILE_READ_TRACKING_QUERY_PARAMS)}` if (isCacheablePreviewRead(request)) { const hostname = getRequestHostname(request).toLowerCase() @@ -108,6 +108,14 @@ export const filesWorkerCacheTestUtils = { export default { async fetch(request: Request, env: Cloudflare.Env, ctx: FilesExecutionContext): Promise { + const fileId = getAttachmentFileIdFromReadPath(new URL(request.url).pathname) + if (fileId && await hasDeletedFileMarker(fileId)) { + return new Response(JSON.stringify({ error: 'not_found', message: 'Not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + } + const cacheKey = buildWorkersCacheKey(request) const cachedFiles = ctx.exports?.CachedFiles if (cacheKey && cachedFiles) diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts new file mode 100644 index 0000000000..0078c6fc63 --- /dev/null +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -0,0 +1,169 @@ +import type { Context } from 'hono' +import { cloudlog } from '../utils/logging.ts' +import { closeClient, getPgClient } from '../utils/pg.ts' + +export const FILE_READ_TRACKING_QUERY_PARAMS = ['device_id'] as const +export const DELETED_FILE_CACHE_HEADER = 'x-capgo-file-deleted' +const DELETED_FILE_MARKER_ORIGIN = 'https://capgo-files-cache.internal' +const FILE_READ_CACHE_ORIGINS = ['https://api.capgo.app'] as const +const FILE_READ_PATH_PREFIXES = [ + '/files/read/attachments/', + '/private/files/read/attachments/', + '/read/attachments/', +] as const + +export function isVersionDeleted(row: { deleted?: boolean | null, deleted_at?: string | Date | null } | null | undefined): boolean { + if (!row) + return false + return row.deleted === true || row.deleted_at != null +} + +export function getAttachmentFileIdFromReadPath(pathname: string): string | null { + for (const prefix of FILE_READ_PATH_PREFIXES) { + if (pathname.startsWith(prefix)) { + const fileId = pathname.slice(prefix.length) + return fileId || null + } + } + return null +} + +export function buildFileReadCacheRequest(request: Request): Request { + const cacheUrl = new URL(request.url) + for (const queryParam of FILE_READ_TRACKING_QUERY_PARAMS) { + cacheUrl.searchParams.delete(queryParam) + } + cacheUrl.searchParams.set('range', request.headers.get('range') || '') + cacheUrl.searchParams.sort() + return new Request(cacheUrl, request) +} + +export function buildDeletedFileMarkerRequest(fileId: string): Request { + return new Request(`${DELETED_FILE_MARKER_ORIGIN}/deleted/${encodeURIComponent(fileId)}`) +} + +function normalizeSearch(url: URL, ignoredParams: readonly string[] = []): string { + const searchParams = new URLSearchParams(url.search) + for (const param of ignoredParams) { + searchParams.delete(param) + } + searchParams.sort() + const search = searchParams.toString() + return search ? `?${search}` : '' +} + +export function buildWorkersFileCacheKey(pathname: string, search = ''): string { + const url = new URL(`https://capgo-files-cache.internal${pathname}${search}`) + return `/files-cache${pathname}${normalizeSearch(url, FILE_READ_TRACKING_QUERY_PARAMS)}` +} + +export function getFileReadCache(): Cache | null { + if (typeof caches === 'undefined') + return null + + const cacheStorage = caches as Cache & { default?: Cache } + if (cacheStorage.default) + return cacheStorage.default + return cacheStorage +} + +export async function hasDeletedFileMarker(fileId: string): Promise { + const cache = getFileReadCache() + if (!cache?.match) + return false + + try { + const cached = await cache.match(buildDeletedFileMarkerRequest(fileId)) + return cached != null + } + catch { + return false + } +} + +export async function markFileDeletedInCache(fileId: string): Promise { + const cache = getFileReadCache() + if (!cache?.put) + return + + await cache.put(buildDeletedFileMarkerRequest(fileId), new Response('deleted', { + status: 404, + headers: { + 'Cache-Control': 'public, max-age=31536000', + [DELETED_FILE_CACHE_HEADER]: '1', + }, + })) +} + +function buildFileReadCacheRequestsForPath(fileId: string): Request[] { + return FILE_READ_CACHE_ORIGINS.flatMap(origin => + FILE_READ_PATH_PREFIXES.map((prefix) => { + const url = new URL(`${prefix}${fileId}`, origin) + url.searchParams.set('range', '') + url.searchParams.sort() + return new Request(url) + }), + ) +} + +function buildWorkersFileCacheRequests(fileId: string): Request[] { + return FILE_READ_PATH_PREFIXES + .filter(prefix => prefix.startsWith('/files/') || prefix.startsWith('/private/')) + .map(prefix => new Request(`${DELETED_FILE_MARKER_ORIGIN}${buildWorkersFileCacheKey(`${prefix}${fileId}`)}`)) +} + +export async function purgeFileReadCache(fileId: string): Promise { + await markFileDeletedInCache(fileId) + + const cache = getFileReadCache() + if (!cache || typeof cache.delete !== 'function') + return + + const requests = [ + ...buildFileReadCacheRequestsForPath(fileId), + ...buildWorkersFileCacheRequests(fileId), + ] + await Promise.all(requests.map(request => cache.delete(request).catch(() => false))) +} + +export async function isAttachmentVersionDeleted(c: Context, fileId: string): Promise { + if (await hasDeletedFileMarker(fileId)) + return true + + let pgClient: ReturnType | null = null + try { + pgClient = getPgClient(c, true) + const result = await pgClient.query<{ deleted: boolean | null, deleted_at: string | null }>( + ` + SELECT deleted, deleted_at + FROM public.app_versions + WHERE r2_path = $1 + AND (COALESCE(deleted, false) = true OR deleted_at IS NOT NULL) + LIMIT 1 + `, + [fileId], + ) + return result.rows.length > 0 + } + catch (error) { + cloudlog({ + requestId: c.get('requestId'), + message: 'isAttachmentVersionDeleted lookup failed, failing open', + fileId, + error: error instanceof Error ? error.message : String(error), + }) + return false + } + finally { + if (pgClient) + await closeClient(c, pgClient) + } +} + +export const fileReadCacheTestUtils = { + buildFileReadCacheRequest, + buildDeletedFileMarkerRequest, + buildWorkersFileCacheKey, + getAttachmentFileIdFromReadPath, + isVersionDeleted, +} diff --git a/supabase/functions/_backend/files/files.ts b/supabase/functions/_backend/files/files.ts index 1473d77585..ae70e94a3b 100644 --- a/supabase/functions/_backend/files/files.ts +++ b/supabase/functions/_backend/files/files.ts @@ -18,6 +18,7 @@ import { checkPermissionPg } from '../utils/rbac.ts' import { createStatsBandwidth } from '../utils/stats.ts' import { supabaseAdmin } from '../utils/supabase.ts' import { backgroundTask } from '../utils/utils.ts' +import { buildFileReadCacheRequest, getFileReadCache, isAttachmentVersionDeleted } from './file_read_cache.ts' import { app as files_config } from './files_config.ts' import { parseUploadMetadata } from './parse.ts' import { DEFAULT_RETRY_PARAMS, RetryBucket } from './retry.ts' @@ -32,7 +33,6 @@ const ATTACHMENT_PREFIX = 'attachments' const ATTACHMENT_PLAN_LIMIT: Array<'mau' | 'bandwidth' | 'storage'> = ['mau', 'bandwidth', 'storage'] const TUS_UPLOAD_CONTENT_TYPE = 'application/offset+octet-stream' const FILE_READ_CACHE_CONTROL = 'public, max-age=31536000, immutable' -const TRACKING_QUERY_PARAMS = ['device_id'] export const app = new Hono() @@ -406,9 +406,9 @@ async function getSupabaseStorageResponse(c: Context, fileId: string): Promise { const fileId = c.get('fileId') - // It is imperative that files are read without any database read to avoid bottlenecks and keep file downloads highly available, especially under heavy load. - // This was designed that way, and access to a file that is going to be deleted is not important compared to download availability. - // Do not add DB or R2 checks before serving the file; if the file is missing in R2, a 404 is expected. + // File reads stay off the primary DB. A deleted version may still be in the + // edge cache after R2 trash; check the deleted marker or one indexed r2_path + // lookup before serving or restoring that cache entry. cloudlog({ requestId: c.get('requestId'), message: 'getHandler files', fileId }) if (getRuntimeKey() !== 'workerd') { @@ -423,28 +423,28 @@ async function getHandler(c: Context): Promise { return c.json({ error: 'not_found', message: 'Not found' }, 404) } - // Support for deno cache or CF cache do not remove this - // @ts-expect-error-next-line - const cache = getRuntimeKey() === 'workerd' ? caches.default : caches + const cache = getFileReadCache() const rawFileId = getRawAttachmentRouteId(c) const candidateKeys = getSafeAttachmentReadCandidateKeys(fileId, rawFileId) - const cacheUrl = new URL(c.req.url) - for (const queryParam of TRACKING_QUERY_PARAMS) { - cacheUrl.searchParams.delete(queryParam) - } - cacheUrl.searchParams.set('range', c.req.header('range') || '') - cacheUrl.searchParams.sort() - const cacheKey = new Request(cacheUrl, c.req) - let response = await cache.match(cacheKey) + const cacheKey = buildFileReadCacheRequest(c.req.raw) + let response = cache ? await cache.match(cacheKey) : null if (response != null) { + if (await isAttachmentVersionDeleted(c, fileId)) { + cloudlog({ requestId: c.get('requestId'), message: 'getHandler files cache hit for deleted version', fileId }) + return c.json({ error: 'not_found', message: 'Not found' }, 404) + } + response = ensureNoTransformResponse(response) cloudlog({ requestId: c.get('requestId'), message: 'getHandler files cache hit' }) if (c.req.raw.method !== 'HEAD') { await saveBandwidthUsage(c, getTransferredBytesFromResponse(response)) } - // Best-effort restore: if file is cached but missing in R2, write it back. + // Best-effort restore: if a live file is cached but missing in R2, write it back. await backgroundTask(c, async () => { try { + if (await isAttachmentVersionDeleted(c, fileId)) + return + const retryBucket = new RetryBucket(bucket, DEFAULT_RETRY_PARAMS) const existingObject = await headFirstExistingAttachmentCandidate(retryBucket, candidateKeys) if (existingObject != null) @@ -521,10 +521,12 @@ async function getHandler(c: Context): Promise { } headers.set('Content-Disposition', `attachment; filename="${object.key}"`) response = new Response(object.body, { headers }) - await backgroundTask(c, () => { - cloudlog({ requestId: c.get('requestId'), message: 'getHandler files cache saved', fileId }) - cache.put(cacheKey, response.clone()) - }) + if (cache && !await isAttachmentVersionDeleted(c, fileId)) { + await backgroundTask(c, () => { + cloudlog({ requestId: c.get('requestId'), message: 'getHandler files cache saved', fileId }) + return cache.put(cacheKey, response.clone()) + }) + } return response } diff --git a/supabase/functions/_backend/triggers/on_version_update.ts b/supabase/functions/_backend/triggers/on_version_update.ts index 9229dba982..9ccca8c457 100644 --- a/supabase/functions/_backend/triggers/on_version_update.ts +++ b/supabase/functions/_backend/triggers/on_version_update.ts @@ -3,6 +3,7 @@ import type { MiddlewareKeyVariables } from '../utils/hono.ts' import type { Database } from '../utils/supabase.types.ts' import { eq } from 'drizzle-orm' import { Hono } from 'hono/tiny' +import { purgeFileReadCache } from '../files/file_read_cache.ts' import { BRES, middlewareAPISecret, simpleError, triggerValidator } from '../utils/hono.ts' import { cloudlog } from '../utils/logging.ts' import { persistVersionManifestEntries } from '../utils/manifest_persist.ts' @@ -425,6 +426,9 @@ async function deleteManifest(c: Context, record: Database['public']['Tables'][' export async function deleteIt(c: Context, record: Database['public']['Tables']['app_versions']['Row']) { cloudlog({ requestId: c.get('requestId'), message: 'Delete', r2_path: record.r2_path }) + if (record.r2_path) + await purgeFileReadCache(record.r2_path) + // Manifest files: trash R2 first, then drop DB rows. Must finish before ACK. await deleteManifest(c, record) diff --git a/tests/files-app-read-guard.unit.test.ts b/tests/files-app-read-guard.unit.test.ts index 50918a1650..b3aeac928f 100644 --- a/tests/files-app-read-guard.unit.test.ts +++ b/tests/files-app-read-guard.unit.test.ts @@ -1,7 +1,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' const getAppByAppIdPgMock = vi.fn(async () => null) -const getPgClientMock = vi.fn(() => ({})) +const getPgClientMock = vi.fn(() => ({ + query: async () => ({ rows: [] }), +})) const originalCaches = globalThis.caches const cachedBodiesByPath = new Map([ ['/read/attachments/orgs/test-org/apps/test-app/orphan.txt', 'cached orphan bytes'], @@ -70,7 +72,7 @@ describe('files app-scoped cached reads', () => { globalThis.caches = originalCaches }) - it.concurrent('serves deleted app-scoped files from cache without a database lookup', async () => { + it.concurrent('serves orphan app-scoped files from cache when no deleted version row exists', async () => { const bucketPut = vi.fn() const appGlobal = await createFilesApp() @@ -85,11 +87,10 @@ describe('files app-scoped cached reads', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('cached orphan bytes') expect(bucketPut).not.toHaveBeenCalled() - expect(getPgClientMock).not.toHaveBeenCalled() expect(getAppByAppIdPgMock).not.toHaveBeenCalled() }) - it.concurrent('serves malformed app-scoped paths from cache without a database lookup', async () => { + it.concurrent('serves malformed app-scoped paths from cache when no deleted version row exists', async () => { const bucketPut = vi.fn() const appGlobal = await createFilesApp() @@ -104,8 +105,6 @@ describe('files app-scoped cached reads', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('cached malformed bytes') expect(bucketPut).not.toHaveBeenCalled() - expect(getPgClientMock).not.toHaveBeenCalled() expect(getAppByAppIdPgMock).not.toHaveBeenCalled() - expect(getPgClientMock).not.toHaveBeenCalled() }) }) diff --git a/tests/files-bandwidth.unit.test.ts b/tests/files-bandwidth.unit.test.ts index 57ea32e9b6..30757ac005 100644 --- a/tests/files-bandwidth.unit.test.ts +++ b/tests/files-bandwidth.unit.test.ts @@ -101,12 +101,16 @@ describe('files bandwidth tracking', () => { it('does not track bandwidth for cached HEAD reads', async () => { globalThis.caches = { default: { - match: async () => new Response(null, { - headers: { - 'cache-control': 'public, max-age=3600', - 'content-length': '3478395', - }, - }), + match: async (request: Request) => { + if (new URL(request.url).pathname.startsWith('/deleted/')) + return null + return new Response(null, { + headers: { + 'cache-control': 'public, max-age=3600', + 'content-length': '3478395', + }, + }) + }, put: async () => { }, }, } as any diff --git a/tests/files-deleted-cache.unit.test.ts b/tests/files-deleted-cache.unit.test.ts new file mode 100644 index 0000000000..0d1d4c6cde --- /dev/null +++ b/tests/files-deleted-cache.unit.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const queryMock = vi.fn() +const closeClientMock = vi.fn() +const getPgClientMock = vi.fn(() => ({ query: queryMock })) + +vi.mock('hono/adapter', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getRuntimeKey: () => 'workerd', + } +}) + +vi.mock('../supabase/functions/_backend/utils/discord.ts', () => ({ + sendDiscordAlert500: () => Promise.resolve(), + sendDiscordAlert: () => Promise.resolve(), +})) + +vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ + closeClient: closeClientMock, + getAppOwnerPostgres: vi.fn(), + getDrizzleClient: vi.fn(() => ({})), + getPgClient: getPgClientMock, +})) + +vi.mock('../supabase/functions/_backend/files/retry.ts', () => ({ + DEFAULT_RETRY_PARAMS: {}, + RetryBucket: class RetryBucketMock { + head() { + return Promise.resolve(null) + } + + get() { + return Promise.resolve(null) + } + }, +})) + +const originalCaches = globalThis.caches + +function createCache(entries: Map = new Map()) { + return { + match: async (request: Request) => { + return entries.get(new URL(request.url).pathname) ?? entries.get(request.url) ?? null + }, + put: async (request: Request, response: Response) => { + entries.set(new URL(request.url).pathname, response) + entries.set(request.url, response) + }, + delete: async (request: Request) => { + entries.delete(new URL(request.url).pathname) + entries.delete(request.url) + return true + }, + entries, + } +} + +async function createFilesApp() { + const { app: files } = await import('../supabase/functions/_backend/files/files.ts') + const { createAllCatch, createHono } = await import('../supabase/functions/_backend/utils/hono.ts') + const { version } = await import('../supabase/functions/_backend/utils/version.ts') + + const appGlobal = createHono('files', version) + appGlobal.route('/', files) + createAllCatch(appGlobal, 'files') + return appGlobal +} + +describe('deleted bundle cache', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + queryMock.mockResolvedValue({ rows: [] }) + }) + + afterEach(() => { + globalThis.caches = originalCaches + }) + + it('does not serve or restore a cached file when the version is deleted', async () => { + queryMock.mockResolvedValue({ + rows: [{ deleted: true, deleted_at: '2026-08-16T00:00:00Z' }], + }) + + const cache = createCache(new Map([ + ['/read/attachments/orgs/test-org/apps/test-app/bundle.zip', new Response('cached deleted bytes', { + headers: { 'content-type': 'application/zip' }, + })], + ])) + globalThis.caches = { default: cache } as any + + const bucketPut = vi.fn() + const appGlobal = await createFilesApp() + const response = await appGlobal.fetch( + new Request('http://localhost/read/attachments/orgs/test-org/apps/test-app/bundle.zip'), + { ATTACHMENT_BUCKET: { put: bucketPut, head: vi.fn(), get: vi.fn() } }, + { waitUntil: () => { } } as any, + ) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ error: 'not_found' }) + expect(bucketPut).not.toHaveBeenCalled() + expect(queryMock).toHaveBeenCalled() + }) + + it('does not serve or restore a cached file when a deleted marker is present', async () => { + const { buildDeletedFileMarkerRequest } = await import('../supabase/functions/_backend/files/file_read_cache.ts') + const cache = createCache(new Map([ + ['/read/attachments/orgs/test-org/apps/test-app/bundle.zip', new Response('cached deleted bytes', { + headers: { 'content-type': 'application/zip' }, + })], + ])) + await cache.put(buildDeletedFileMarkerRequest('orgs/test-org/apps/test-app/bundle.zip'), new Response('deleted')) + globalThis.caches = { default: cache } as any + + const bucketPut = vi.fn() + const appGlobal = await createFilesApp() + const response = await appGlobal.fetch( + new Request('http://localhost/read/attachments/orgs/test-org/apps/test-app/bundle.zip'), + { ATTACHMENT_BUCKET: { put: bucketPut, head: vi.fn(), get: vi.fn() } }, + { waitUntil: () => { } } as any, + ) + + expect(response.status).toBe(404) + expect(bucketPut).not.toHaveBeenCalled() + expect(queryMock).not.toHaveBeenCalled() + }) + + it('purges file cache keys and writes a deleted marker', async () => { + const cache = createCache() + globalThis.caches = { default: cache } as any + + const { buildDeletedFileMarkerRequest, purgeFileReadCache } = await import('../supabase/functions/_backend/files/file_read_cache.ts') + const fileId = 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip' + await purgeFileReadCache(fileId) + + const marker = await cache.match(buildDeletedFileMarkerRequest(fileId)) + expect(marker).not.toBeNull() + expect(marker?.status).toBe(404) + }) + + it('treats deleted and deleted_at as deleted versions', async () => { + const { isVersionDeleted } = await import('../supabase/functions/_backend/files/file_read_cache.ts') + + expect(isVersionDeleted({ deleted: true, deleted_at: null })).toBe(true) + expect(isVersionDeleted({ deleted: false, deleted_at: '2026-08-16T00:00:00Z' })).toBe(true) + expect(isVersionDeleted({ deleted: false, deleted_at: null })).toBe(false) + expect(isVersionDeleted(null)).toBe(false) + }) +}) diff --git a/tests/files-r2-error.test.ts b/tests/files-r2-error.test.ts index 7843ce6000..c967e9f52a 100644 --- a/tests/files-r2-error.test.ts +++ b/tests/files-r2-error.test.ts @@ -76,6 +76,9 @@ describe('files R2 error handling', () => { const matchMock = vi.fn(async (request: Request) => { const cacheUrl = new URL(request.url) + if (cacheUrl.pathname.startsWith('/deleted/')) + return null + expect(cacheUrl.searchParams.get('device_id')).toBeNull() expect(cacheUrl.searchParams.get('key')).toBe('checksum') expect(cacheUrl.searchParams.get('range')).toBe('') @@ -112,7 +115,7 @@ describe('files R2 error handling', () => { expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable, no-transform') - expect(matchMock).toHaveBeenCalledTimes(1) + expect(matchMock).toHaveBeenCalled() }) it('should persist no-transform in file metadata written to R2', async () => { diff --git a/tests/on-version-update-cleanup.unit.test.ts b/tests/on-version-update-cleanup.unit.test.ts index 436f2c7b89..dc0747239b 100644 --- a/tests/on-version-update-cleanup.unit.test.ts +++ b/tests/on-version-update-cleanup.unit.test.ts @@ -13,6 +13,7 @@ const { manifestSelectWhere, moveObjectToTrash, pgQuery, + purgeFileReadCache, supabaseAdmin, } = vi.hoisted(() => { const callOrder: string[] = [] @@ -75,10 +76,15 @@ const { manifestSelectWhere, moveObjectToTrash, pgQuery, + purgeFileReadCache: vi.fn(async () => {}), supabaseAdmin: vi.fn(() => ({ from: supabaseFrom })), } }) +vi.mock('../supabase/functions/_backend/files/file_read_cache.ts', () => ({ + purgeFileReadCache, +})) + vi.mock('../supabase/functions/_backend/utils/s3.ts', () => ({ getPath: vi.fn(), s3: { @@ -179,6 +185,7 @@ describe('on_version_update deleted version cleanup', () => { it('moves the bundle to trash and clears stored size for soft-deleted versions', async () => { const response = await deleteIt(createContext(), createVersion()) expect(response.status).toBe(200) + expect(purgeFileReadCache).toHaveBeenCalledWith('orgs/org-1/apps/com.cleanup.test/1.0.0.zip') expect(moveObjectToTrash).toHaveBeenCalledWith(expect.anything(), 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip') expect(appVersionsMetaUpdate).toHaveBeenCalledWith({ size: 0 }) }) From 4bacb1f79f1d07e929b7f7c6fda594ff3a288df1 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 16 Aug 2026 23:31:49 +0300 Subject: [PATCH 02/20] fix(security): satisfy deleted-cache typecheck Co-authored-by: Cursor --- supabase/functions/_backend/files/file_read_cache.ts | 2 +- supabase/functions/_backend/files/files.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts index 0078c6fc63..39d78fa8e3 100644 --- a/supabase/functions/_backend/files/file_read_cache.ts +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -61,7 +61,7 @@ export function getFileReadCache(): Cache | null { if (typeof caches === 'undefined') return null - const cacheStorage = caches as Cache & { default?: Cache } + const cacheStorage = caches as unknown as Cache & { default?: Cache, open?: (cacheName: string) => Promise } if (cacheStorage.default) return cacheStorage.default return cacheStorage diff --git a/supabase/functions/_backend/files/files.ts b/supabase/functions/_backend/files/files.ts index ae70e94a3b..6336bd3284 100644 --- a/supabase/functions/_backend/files/files.ts +++ b/supabase/functions/_backend/files/files.ts @@ -434,10 +434,11 @@ async function getHandler(c: Context): Promise { return c.json({ error: 'not_found', message: 'Not found' }, 404) } - response = ensureNoTransformResponse(response) + const cachedResponse = ensureNoTransformResponse(response) + response = cachedResponse cloudlog({ requestId: c.get('requestId'), message: 'getHandler files cache hit' }) if (c.req.raw.method !== 'HEAD') { - await saveBandwidthUsage(c, getTransferredBytesFromResponse(response)) + await saveBandwidthUsage(c, getTransferredBytesFromResponse(cachedResponse)) } // Best-effort restore: if a live file is cached but missing in R2, write it back. await backgroundTask(c, async () => { @@ -450,7 +451,7 @@ async function getHandler(c: Context): Promise { if (existingObject != null) return - const cached = response.clone() + const cached = cachedResponse.clone() const data = await cached.arrayBuffer() const contentType = cached.headers.get('content-type') || undefined const httpMetadata = buildFileHttpMetadata(contentType, cached.headers.get('cache-control')) @@ -464,7 +465,7 @@ async function getHandler(c: Context): Promise { cloudlog({ requestId: c.get('requestId'), message: 'Failed to restore cached file to R2', fileId, error: String(err) }) } }) - return response + return cachedResponse } const rangeHeaderFromRequest = c.req.header('range') From bd8d65ed903ebf478e25a047d7bc490860530e6c Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 16 Aug 2026 23:33:08 +0300 Subject: [PATCH 03/20] Revert "test(security): accept bundle.delete trigger on upload soft-delete" This reverts commit dd49c2d1726a40bb8ea0bf2b301cd0438e778c2e. --- tests/rbac-apikey-request-identity-rpc.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rbac-apikey-request-identity-rpc.test.ts b/tests/rbac-apikey-request-identity-rpc.test.ts index 6936c0b17f..be29a22954 100644 --- a/tests/rbac-apikey-request-identity-rpc.test.ts +++ b/tests/rbac-apikey-request-identity-rpc.test.ts @@ -278,7 +278,7 @@ describe('app_versions RBAC update policy', () => { .select('id, deleted') if (deletedError) { - expect(deletedError.message.toLowerCase()).toMatch(/row|permission_denied_bundle_delete/) + expect(deletedError.message.toLowerCase()).toContain('row') } else { expect(deletedUpdate).toEqual([]) From 1ea43bceafc00be40a9c62430c6cfcb8bd45aeed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 15:57:16 +0000 Subject: [PATCH 04/20] fix(security): harden deleted bundle cache purge and serve paths Co-authored-by: Martin DONADIEU --- cloudflare_workers/files/index.ts | 11 ++++- .../_backend/files/file_read_cache.ts | 42 +++++++++++++++---- supabase/functions/_backend/files/files.ts | 10 ++++- .../_backend/triggers/on_version_update.ts | 15 ++++++- tests/files-deleted-cache.unit.test.ts | 9 +++- tests/files-r2-error.test.ts | 14 ++++++- 6 files changed, 86 insertions(+), 15 deletions(-) diff --git a/cloudflare_workers/files/index.ts b/cloudflare_workers/files/index.ts index af591a1669..2c1056c872 100644 --- a/cloudflare_workers/files/index.ts +++ b/cloudflare_workers/files/index.ts @@ -108,7 +108,16 @@ export const filesWorkerCacheTestUtils = { export default { async fetch(request: Request, env: Cloudflare.Env, ctx: FilesExecutionContext): Promise { - const fileId = getAttachmentFileIdFromReadPath(new URL(request.url).pathname) + const rawFileId = getAttachmentFileIdFromReadPath(new URL(request.url).pathname) + let fileId: string | null = rawFileId + if (rawFileId) { + try { + fileId = decodeURIComponent(rawFileId) + } + catch { + // Let the files handler return its invalid-path response. + } + } if (fileId && await hasDeletedFileMarker(fileId)) { return new Response(JSON.stringify({ error: 'not_found', message: 'Not found' }), { status: 404, diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts index 39d78fa8e3..c6fc37162c 100644 --- a/supabase/functions/_backend/files/file_read_cache.ts +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -1,10 +1,12 @@ import type { Context } from 'hono' +import { getRuntimeKey } from 'hono/adapter' import { cloudlog } from '../utils/logging.ts' import { closeClient, getPgClient } from '../utils/pg.ts' export const FILE_READ_TRACKING_QUERY_PARAMS = ['device_id'] as const export const DELETED_FILE_CACHE_HEADER = 'x-capgo-file-deleted' const DELETED_FILE_MARKER_ORIGIN = 'https://capgo-files-cache.internal' +const FILE_READ_CACHE_NAME = 'capgo-file-read-cache' const FILE_READ_CACHE_ORIGINS = ['https://api.capgo.app'] as const const FILE_READ_PATH_PREFIXES = [ '/files/read/attachments/', @@ -57,18 +59,40 @@ export function buildWorkersFileCacheKey(pathname: string, search = ''): string return `/files-cache${pathname}${normalizeSearch(url, FILE_READ_TRACKING_QUERY_PARAMS)}` } -export function getFileReadCache(): Cache | null { +type CacheLike = Cache & { + default?: Cache + open?: (cacheName: string) => Promise +} + +let fileReadCachePromise: Promise | null = null + +async function resolveFileReadCache(): Promise { if (typeof caches === 'undefined') return null - const cacheStorage = caches as unknown as Cache & { default?: Cache, open?: (cacheName: string) => Promise } - if (cacheStorage.default) + const cacheStorage = caches as unknown as CacheLike + if (getRuntimeKey() === 'workerd' && cacheStorage.default) return cacheStorage.default - return cacheStorage + + if (typeof cacheStorage.open === 'function') { + try { + return await cacheStorage.open(FILE_READ_CACHE_NAME) + } + catch { + return null + } + } + + return null +} + +export async function getFileReadCache(): Promise { + fileReadCachePromise ??= resolveFileReadCache() + return fileReadCachePromise } export async function hasDeletedFileMarker(fileId: string): Promise { - const cache = getFileReadCache() + const cache = await getFileReadCache() if (!cache?.match) return false @@ -82,7 +106,7 @@ export async function hasDeletedFileMarker(fileId: string): Promise { } export async function markFileDeletedInCache(fileId: string): Promise { - const cache = getFileReadCache() + const cache = await getFileReadCache() if (!cache?.put) return @@ -115,7 +139,7 @@ function buildWorkersFileCacheRequests(fileId: string): Request[] { export async function purgeFileReadCache(fileId: string): Promise { await markFileDeletedInCache(fileId) - const cache = getFileReadCache() + const cache = await getFileReadCache() if (!cache || typeof cache.delete !== 'function') return @@ -148,11 +172,11 @@ export async function isAttachmentVersionDeleted(c: Context, fileId: string): Pr catch (error) { cloudlog({ requestId: c.get('requestId'), - message: 'isAttachmentVersionDeleted lookup failed, failing open', + message: 'isAttachmentVersionDeleted lookup failed, failing closed', fileId, error: error instanceof Error ? error.message : String(error), }) - return false + return true } finally { if (pgClient) diff --git a/supabase/functions/_backend/files/files.ts b/supabase/functions/_backend/files/files.ts index 6336bd3284..91dbdcc7ed 100644 --- a/supabase/functions/_backend/files/files.ts +++ b/supabase/functions/_backend/files/files.ts @@ -423,7 +423,7 @@ async function getHandler(c: Context): Promise { return c.json({ error: 'not_found', message: 'Not found' }, 404) } - const cache = getFileReadCache() + const cache = await getFileReadCache() const rawFileId = getRawAttachmentRouteId(c) const candidateKeys = getSafeAttachmentReadCandidateKeys(fileId, rawFileId) const cacheKey = buildFileReadCacheRequest(c.req.raw) @@ -453,6 +453,9 @@ async function getHandler(c: Context): Promise { const cached = cachedResponse.clone() const data = await cached.arrayBuffer() + if (await isAttachmentVersionDeleted(c, fileId)) + return + const contentType = cached.headers.get('content-type') || undefined const httpMetadata = buildFileHttpMetadata(contentType, cached.headers.get('cache-control')) await bucket.put(fileId, data, { httpMetadata }) @@ -468,6 +471,11 @@ async function getHandler(c: Context): Promise { return cachedResponse } + if (await isAttachmentVersionDeleted(c, fileId)) { + cloudlog({ requestId: c.get('requestId'), message: 'getHandler files cache miss for deleted version', fileId }) + return c.json({ error: 'not_found', message: 'Not found' }, 404) + } + const rangeHeaderFromRequest = c.req.header('range') if (rangeHeaderFromRequest) { cloudlog({ requestId: c.get('requestId'), message: 'getHandler files range request', range: rangeHeaderFromRequest }) diff --git a/supabase/functions/_backend/triggers/on_version_update.ts b/supabase/functions/_backend/triggers/on_version_update.ts index 9ccca8c457..0d2c1463f2 100644 --- a/supabase/functions/_backend/triggers/on_version_update.ts +++ b/supabase/functions/_backend/triggers/on_version_update.ts @@ -426,8 +426,19 @@ async function deleteManifest(c: Context, record: Database['public']['Tables'][' export async function deleteIt(c: Context, record: Database['public']['Tables']['app_versions']['Row']) { cloudlog({ requestId: c.get('requestId'), message: 'Delete', r2_path: record.r2_path }) - if (record.r2_path) - await purgeFileReadCache(record.r2_path) + if (record.r2_path) { + try { + await purgeFileReadCache(record.r2_path) + } + catch (error) { + cloudlog({ + requestId: c.get('requestId'), + message: 'purgeFileReadCache failed during version delete', + r2_path: record.r2_path, + error, + }) + } + } // Manifest files: trash R2 first, then drop DB rows. Must finish before ACK. await deleteManifest(c, record) diff --git a/tests/files-deleted-cache.unit.test.ts b/tests/files-deleted-cache.unit.test.ts index 0d1d4c6cde..66e3047859 100644 --- a/tests/files-deleted-cache.unit.test.ts +++ b/tests/files-deleted-cache.unit.test.ts @@ -132,10 +132,17 @@ describe('deleted bundle cache', () => { const cache = createCache() globalThis.caches = { default: cache } as any - const { buildDeletedFileMarkerRequest, purgeFileReadCache } = await import('../supabase/functions/_backend/files/file_read_cache.ts') + const { buildDeletedFileMarkerRequest, buildFileReadCacheRequest, purgeFileReadCache } = await import('../supabase/functions/_backend/files/file_read_cache.ts') const fileId = 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip' + const readRequest = buildFileReadCacheRequest(new Request(`https://api.capgo.app/files/read/attachments/${fileId}`)) + await cache.put(readRequest, new Response('cached bundle bytes', { + headers: { 'content-type': 'application/zip' }, + })) + expect(await cache.match(readRequest)).not.toBeNull() + await purgeFileReadCache(fileId) + expect(await cache.match(readRequest)).toBeNull() const marker = await cache.match(buildDeletedFileMarkerRequest(fileId)) expect(marker).not.toBeNull() expect(marker?.status).toBe(404) diff --git a/tests/files-r2-error.test.ts b/tests/files-r2-error.test.ts index c967e9f52a..182d31f5cb 100644 --- a/tests/files-r2-error.test.ts +++ b/tests/files-r2-error.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it, vi } from 'vitest' const retryGetMock = vi.fn() const retryHeadMock = vi.fn() +const queryMock = vi.fn() +const closeClientMock = vi.fn() +const getPgClientMock = vi.fn(() => ({ query: queryMock })) vi.mock('cloudflare:workers', () => ({ DurableObject: class DurableObjectMock {}, @@ -20,6 +23,13 @@ vi.mock('../supabase/functions/_backend/utils/discord.ts', () => ({ sendDiscordAlert: () => Promise.resolve(), })) +vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ + closeClient: closeClientMock, + getAppOwnerPostgres: vi.fn(), + getDrizzleClient: vi.fn(() => ({})), + getPgClient: getPgClientMock, +})) + vi.mock('../supabase/functions/_backend/files/retry.ts', () => ({ DEFAULT_RETRY_PARAMS: {}, RetryBucket: class RetryBucketMock { @@ -37,6 +47,7 @@ vi.mock('../supabase/functions/_backend/files/retry.ts', () => ({ describe('files R2 error handling', () => { it('should return 503 when R2 get fails', async () => { vi.resetModules() + queryMock.mockResolvedValue({ rows: [] }) retryHeadMock.mockResolvedValue(null) retryGetMock.mockImplementation(() => { throw new Error('r2 unavailable') @@ -71,6 +82,7 @@ describe('files R2 error handling', () => { it('should add immutable cache control and strip tracking params on cached responses', async () => { vi.resetModules() + queryMock.mockResolvedValue({ rows: [] }) retryHeadMock.mockResolvedValue(null) retryGetMock.mockResolvedValue(null) @@ -115,7 +127,7 @@ describe('files R2 error handling', () => { expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable, no-transform') - expect(matchMock).toHaveBeenCalled() + expect(matchMock).toHaveBeenCalledTimes(2) }) it('should persist no-transform in file metadata written to R2', async () => { From 4970807dfe0edc2d7295161b4ef2794eb7a1b61b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 16:13:25 +0000 Subject: [PATCH 05/20] test(files): mock pg client in bandwidth unit tests for deleted guard Co-authored-by: Martin DONADIEU --- tests/files-bandwidth.unit.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/files-bandwidth.unit.test.ts b/tests/files-bandwidth.unit.test.ts index 30757ac005..dadd9fb7e1 100644 --- a/tests/files-bandwidth.unit.test.ts +++ b/tests/files-bandwidth.unit.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const retryGetMock = vi.fn() const createStatsBandwidthMock = vi.fn() +const queryMock = vi.fn() +const closeClientMock = vi.fn() +const getPgClientMock = vi.fn(() => ({ query: queryMock })) vi.mock('hono/adapter', async (importOriginal) => { const actual = await importOriginal() @@ -16,6 +19,13 @@ vi.mock('../supabase/functions/_backend/utils/discord.ts', () => ({ sendDiscordAlert: () => Promise.resolve(), })) +vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ + closeClient: closeClientMock, + getAppOwnerPostgres: vi.fn(), + getDrizzleClient: vi.fn(() => ({})), + getPgClient: getPgClientMock, +})) + vi.mock('../supabase/functions/_backend/files/retry.ts', () => ({ DEFAULT_RETRY_PARAMS: {}, RetryBucket: class RetryBucketMock { @@ -64,9 +74,14 @@ describe('files bandwidth tracking', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + queryMock.mockResolvedValue({ rows: [] }) globalThis.caches = { default: { - match: async () => null, + match: async (request: Request) => { + if (new URL(request.url).pathname.startsWith('/deleted/')) + return null + return null + }, put: async () => { }, }, } as any From 9a8a556bb5f6d9d13f3b00bb4c76806e1209c797 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 14:24:21 +0000 Subject: [PATCH 06/20] fix(test): align upload-only delete assertion with bundle delete guard Accept PERMISSION_DENIED_BUNDLE_DELETE from enforce_app_versions_delete_permission instead of legacy PostgREST row-not-found wording. ci: isolate pull_request test runs from cross-commit cancellation Co-authored-by: Martin DONADIEU --- .github/workflows/tests.yml | 21 ++++++++++--------- .../rbac-apikey-request-identity-rpc.test.ts | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 61aeffb7d7..ae18a0aa7a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,8 @@ name: Run tests concurrency: # Include event_name so push and pull_request on the same branch do not cancel each other. - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_call' && github.sha || github.head_ref || github.ref_name || github.ref }} + # Include PR head SHA so re-triggers on the same commit do not cancel an in-flight run. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_call' && github.sha || (github.event_name == 'pull_request' && format('{0}-{1}', github.head_ref || github.ref_name, github.event.pull_request.head.sha) || github.head_ref || github.ref_name || github.ref) }} # Keep in-progress pull_request suites running; duplicate synchronize events were # cancelling shards mid-queue and leaving cancelled required checks on the PR. cancel-in-progress: ${{ github.event_name != 'pull_request' }} @@ -323,13 +324,13 @@ jobs: actions: write concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-backend-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }} + group: capgo-local-services-backend-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard_id }} SUPABASE_WORKTREE_PORT_OFFSET: ${{ matrix.supabase_port_offset }} strategy: - fail-fast: true + fail-fast: false matrix: include: - shard: 1/6 @@ -489,7 +490,7 @@ jobs: contents: read actions: write concurrency: - group: capgo-local-services-backend-plugin-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-backend-plugin-${{ github.event_name }}-${{ github.repository }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-plugin-${{ github.run_id }}-${{ github.run_attempt }} @@ -599,7 +600,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-backend-sql-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-backend-sql-${{ github.event_name }}-${{ github.repository }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-sql-${{ github.run_id }}-${{ github.run_attempt }} @@ -669,7 +670,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-backend-sql-catalog-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-backend-sql-catalog-${{ github.event_name }}-${{ github.repository }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-sql-catalog-${{ github.run_id }}-${{ github.run_attempt }} @@ -741,7 +742,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-cloudflare-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }} + group: capgo-local-services-cloudflare-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: cloudflare-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard_id }} @@ -749,7 +750,7 @@ jobs: CLOUDFLARE_WORKER_PORT_OFFSET: ${{ matrix.cloudflare_worker_port_offset }} CLOUDFLARE_PERSIST_DIR: .context/cloudflare-workers-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.supabase_port_offset }} strategy: - fail-fast: true + fail-fast: false matrix: include: - shard: 1/8 @@ -883,7 +884,7 @@ jobs: permissions: contents: read concurrency: - group: capgo-local-services-cloudflare-plugin-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-cloudflare-plugin-${{ github.event_name }}-${{ github.repository }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: cloudflare-plugin-${{ github.run_id }}-${{ github.run_attempt }} @@ -998,7 +999,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-playwright-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }} + group: capgo-local-services-playwright-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }}-${{ github.sha }} cancel-in-progress: false strategy: fail-fast: false diff --git a/tests/rbac-apikey-request-identity-rpc.test.ts b/tests/rbac-apikey-request-identity-rpc.test.ts index be29a22954..50f569f298 100644 --- a/tests/rbac-apikey-request-identity-rpc.test.ts +++ b/tests/rbac-apikey-request-identity-rpc.test.ts @@ -278,7 +278,7 @@ describe('app_versions RBAC update policy', () => { .select('id, deleted') if (deletedError) { - expect(deletedError.message.toLowerCase()).toContain('row') + expect(deletedError.message).toMatch(/PERMISSION_DENIED_BUNDLE_DELETE/i) } else { expect(deletedUpdate).toEqual([]) From 19ba5ffed84a9d22351de13d89f12d320219250d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 14:54:57 +0000 Subject: [PATCH 07/20] test(channel_self): retry Kong 502/503 and warm plugin endpoint Use fetchTestRequest for /channel_self calls and warm the Deno isolate before assertions so serial plugin CI does not flake on cold 502s. Co-authored-by: Martin DONADIEU --- tests/channel_self.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/channel_self.test.ts b/tests/channel_self.test.ts index 934c0b2c66..654b750b10 100644 --- a/tests/channel_self.test.ts +++ b/tests/channel_self.test.ts @@ -3,7 +3,7 @@ import type { DeviceLink, HttpMethod } from './test-utils.ts' import { randomUUID } from 'node:crypto' import { env } from 'node:process' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { getBaseData, getSupabaseClient, PLUGIN_BASE_URL, resetAndSeedAppData, resetAppData, resetAppDataStats } from './test-utils.ts' +import { fetchTestRequest, getBaseData, getSupabaseClient, PLUGIN_BASE_URL, resetAndSeedAppData, resetAppData, resetAppDataStats, warmEdgeEndpoint } from './test-utils.ts' interface ChannelInfo { id: number @@ -32,12 +32,10 @@ async function fetchEndpoint(method: HttpMethod, bodyIn: object) { } const body = method !== 'DELETE' ? JSON.stringify(bodyIn) : undefined - const response = await fetch(url, { + return fetchTestRequest(url.toString(), { method, body, }) - - return response } async function fetchGetChannels(queryParams: Record) { @@ -45,11 +43,9 @@ async function fetchGetChannels(queryParams: Record) { for (const [key, value] of Object.entries(queryParams)) url.searchParams.append(key, value) - const response = await fetch(url, { + return fetchTestRequest(url.toString(), { method: 'GET', }) - - return response } async function getResponseErrorCode(response: Response) { @@ -67,6 +63,12 @@ async function withSupabaseCall { await resetAndSeedAppData(APPNAME) + // Cold first /channel_self request can 502 under Deno shard load; warm before assertions. + await warmEdgeEndpoint(`${PLUGIN_BASE_URL}/channel_self`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(getBaseData(APPNAME)), + }) }) afterAll(async () => { await resetAppData(APPNAME) From eaee5d519b5fdef8eec539ea48218c04297ed8bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 15:08:40 +0000 Subject: [PATCH 08/20] fix(files): purge keyed file-read cache entries on bundle delete Pass bundle checksum into purgeFileReadCache so key=checksum cache variants are deleted, and reuse a shared read-only pg pool for deleted lookups. Co-authored-by: Martin DONADIEU --- .../_backend/files/file_read_cache.ts | 59 +++++++++++++------ .../_backend/triggers/on_version_update.ts | 2 +- tests/files-deleted-cache.unit.test.ts | 9 ++- tests/on-version-update-cleanup.unit.test.ts | 1 + 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts index c6fc37162c..b3c8394512 100644 --- a/supabase/functions/_backend/files/file_read_cache.ts +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono' import { getRuntimeKey } from 'hono/adapter' import { cloudlog } from '../utils/logging.ts' -import { closeClient, getPgClient } from '../utils/pg.ts' +import { getDatabaseURL, getPgClient } from '../utils/pg.ts' export const FILE_READ_TRACKING_QUERY_PARAMS = ['device_id'] as const export const DELETED_FILE_CACHE_HEADER = 'x-capgo-file-deleted' @@ -119,24 +119,52 @@ export async function markFileDeletedInCache(fileId: string): Promise { })) } -function buildFileReadCacheRequestsForPath(fileId: string): Request[] { +let sharedReadOnlyPool: ReturnType | null = null +let sharedReadOnlyPoolUrl: string | null = null + +function getSharedReadOnlyPgClient(c: Context): ReturnType { + const dbUrl = getDatabaseURL(c, true) + if (!sharedReadOnlyPool || sharedReadOnlyPoolUrl !== dbUrl) { + sharedReadOnlyPool = getPgClient(c, true) + sharedReadOnlyPoolUrl = dbUrl + } + return sharedReadOnlyPool +} + +function buildFileReadCacheRequestsForPath(fileId: string, checksum?: string | null): Request[] { + const identityParamSets: Array> = [{}] + if (checksum) + identityParamSets.push({ key: checksum }) + return FILE_READ_CACHE_ORIGINS.flatMap(origin => - FILE_READ_PATH_PREFIXES.map((prefix) => { - const url = new URL(`${prefix}${fileId}`, origin) - url.searchParams.set('range', '') - url.searchParams.sort() - return new Request(url) + FILE_READ_PATH_PREFIXES.flatMap((prefix) => { + return identityParamSets.map((identityParams) => { + const url = new URL(`${prefix}${fileId}`, origin) + for (const [key, value] of Object.entries(identityParams)) + url.searchParams.set(key, value) + url.searchParams.set('range', '') + url.searchParams.sort() + return new Request(url) + }) }), ) } -function buildWorkersFileCacheRequests(fileId: string): Request[] { +function buildWorkersFileCacheRequests(fileId: string, checksum?: string | null): Request[] { + const searchVariants = [''] + if (checksum) + searchVariants.push(`?key=${encodeURIComponent(checksum)}`) + return FILE_READ_PATH_PREFIXES .filter(prefix => prefix.startsWith('/files/') || prefix.startsWith('/private/')) - .map(prefix => new Request(`${DELETED_FILE_MARKER_ORIGIN}${buildWorkersFileCacheKey(`${prefix}${fileId}`)}`)) + .flatMap(prefix => + searchVariants.map(search => + new Request(`${DELETED_FILE_MARKER_ORIGIN}${buildWorkersFileCacheKey(`${prefix}${fileId}`, search)}`), + ), + ) } -export async function purgeFileReadCache(fileId: string): Promise { +export async function purgeFileReadCache(fileId: string, checksum?: string | null): Promise { await markFileDeletedInCache(fileId) const cache = await getFileReadCache() @@ -144,8 +172,8 @@ export async function purgeFileReadCache(fileId: string): Promise { return const requests = [ - ...buildFileReadCacheRequestsForPath(fileId), - ...buildWorkersFileCacheRequests(fileId), + ...buildFileReadCacheRequestsForPath(fileId, checksum), + ...buildWorkersFileCacheRequests(fileId, checksum), ] await Promise.all(requests.map(request => cache.delete(request).catch(() => false))) } @@ -154,9 +182,8 @@ export async function isAttachmentVersionDeleted(c: Context, fileId: string): Pr if (await hasDeletedFileMarker(fileId)) return true - let pgClient: ReturnType | null = null try { - pgClient = getPgClient(c, true) + const pgClient = getSharedReadOnlyPgClient(c) const result = await pgClient.query<{ deleted: boolean | null, deleted_at: string | null }>( ` SELECT deleted, deleted_at @@ -178,10 +205,6 @@ export async function isAttachmentVersionDeleted(c: Context, fileId: string): Pr }) return true } - finally { - if (pgClient) - await closeClient(c, pgClient) - } } export const fileReadCacheTestUtils = { diff --git a/supabase/functions/_backend/triggers/on_version_update.ts b/supabase/functions/_backend/triggers/on_version_update.ts index 0d2c1463f2..06bd531183 100644 --- a/supabase/functions/_backend/triggers/on_version_update.ts +++ b/supabase/functions/_backend/triggers/on_version_update.ts @@ -428,7 +428,7 @@ export async function deleteIt(c: Context, record: Database['public']['Tables'][ if (record.r2_path) { try { - await purgeFileReadCache(record.r2_path) + await purgeFileReadCache(record.r2_path, record.checksum) } catch (error) { cloudlog({ diff --git a/tests/files-deleted-cache.unit.test.ts b/tests/files-deleted-cache.unit.test.ts index 66e3047859..3b61119116 100644 --- a/tests/files-deleted-cache.unit.test.ts +++ b/tests/files-deleted-cache.unit.test.ts @@ -134,15 +134,22 @@ describe('deleted bundle cache', () => { const { buildDeletedFileMarkerRequest, buildFileReadCacheRequest, purgeFileReadCache } = await import('../supabase/functions/_backend/files/file_read_cache.ts') const fileId = 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip' + const checksum = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' const readRequest = buildFileReadCacheRequest(new Request(`https://api.capgo.app/files/read/attachments/${fileId}`)) + const keyedReadRequest = buildFileReadCacheRequest(new Request(`https://api.capgo.app/files/read/attachments/${fileId}?device_id=device-1&key=${checksum}`)) await cache.put(readRequest, new Response('cached bundle bytes', { headers: { 'content-type': 'application/zip' }, })) + await cache.put(keyedReadRequest, new Response('cached keyed bundle bytes', { + headers: { 'content-type': 'application/zip' }, + })) expect(await cache.match(readRequest)).not.toBeNull() + expect(await cache.match(keyedReadRequest)).not.toBeNull() - await purgeFileReadCache(fileId) + await purgeFileReadCache(fileId, checksum) expect(await cache.match(readRequest)).toBeNull() + expect(await cache.match(keyedReadRequest)).toBeNull() const marker = await cache.match(buildDeletedFileMarkerRequest(fileId)) expect(marker).not.toBeNull() expect(marker?.status).toBe(404) diff --git a/tests/on-version-update-cleanup.unit.test.ts b/tests/on-version-update-cleanup.unit.test.ts index dc0747239b..11bf67e475 100644 --- a/tests/on-version-update-cleanup.unit.test.ts +++ b/tests/on-version-update-cleanup.unit.test.ts @@ -130,6 +130,7 @@ function createVersion(overrides: Record = {}) { name: '1.0.0', owner_org: 'org-1', r2_path: 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip', + checksum: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', storage_provider: 'r2', ...overrides, } as any From eeadc474ec19c0518ab386bae25520b631c49cef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 15:09:45 +0000 Subject: [PATCH 09/20] test(files): mock getDatabaseURL for shared read-only pool lookup Co-authored-by: Martin DONADIEU --- tests/files-deleted-cache.unit.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/files-deleted-cache.unit.test.ts b/tests/files-deleted-cache.unit.test.ts index 3b61119116..6b5d998a49 100644 --- a/tests/files-deleted-cache.unit.test.ts +++ b/tests/files-deleted-cache.unit.test.ts @@ -20,6 +20,7 @@ vi.mock('../supabase/functions/_backend/utils/discord.ts', () => ({ vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ closeClient: closeClientMock, getAppOwnerPostgres: vi.fn(), + getDatabaseURL: vi.fn(() => 'postgres://test'), getDrizzleClient: vi.fn(() => ({})), getPgClient: getPgClientMock, })) From 1802f43f96abc820918e945a995dde38091e86e4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 15:41:40 +0000 Subject: [PATCH 10/20] test(files): assert checksum passed to purgeFileReadCache on delete Co-authored-by: Martin DONADIEU --- tests/on-version-update-cleanup.unit.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/on-version-update-cleanup.unit.test.ts b/tests/on-version-update-cleanup.unit.test.ts index 11bf67e475..425fe1c129 100644 --- a/tests/on-version-update-cleanup.unit.test.ts +++ b/tests/on-version-update-cleanup.unit.test.ts @@ -186,7 +186,10 @@ describe('on_version_update deleted version cleanup', () => { it('moves the bundle to trash and clears stored size for soft-deleted versions', async () => { const response = await deleteIt(createContext(), createVersion()) expect(response.status).toBe(200) - expect(purgeFileReadCache).toHaveBeenCalledWith('orgs/org-1/apps/com.cleanup.test/1.0.0.zip') + expect(purgeFileReadCache).toHaveBeenCalledWith( + 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ) expect(moveObjectToTrash).toHaveBeenCalledWith(expect.anything(), 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip') expect(appVersionsMetaUpdate).toHaveBeenCalledWith({ size: 0 }) }) From f74b339af28d0a056d80fb79eb96c27aee527da0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 15:49:10 +0000 Subject: [PATCH 11/20] ci: scope push Run tests concurrency by commit SHA Push events shared one concurrency group per branch, so a stale queued run blocked all later commits from starting required checks. Co-authored-by: Martin DONADIEU --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ae18a0aa7a..901d13b288 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,7 +3,7 @@ name: Run tests concurrency: # Include event_name so push and pull_request on the same branch do not cancel each other. # Include PR head SHA so re-triggers on the same commit do not cancel an in-flight run. - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_call' && github.sha || (github.event_name == 'pull_request' && format('{0}-{1}', github.head_ref || github.ref_name, github.event.pull_request.head.sha) || github.head_ref || github.ref_name || github.ref) }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_call' && github.sha || (github.event_name == 'pull_request' && format('{0}-{1}', github.head_ref || github.ref_name, github.event.pull_request.head.sha) || format('{0}-{1}', github.head_ref || github.ref_name || github.ref, github.sha)) }} # Keep in-progress pull_request suites running; duplicate synchronize events were # cancelling shards mid-queue and leaving cancelled required checks on the PR. cancel-in-progress: ${{ github.event_name != 'pull_request' }} From 0dea67a4d8b414ff1bd82f13504a5ffa5e8223a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 15:55:45 +0000 Subject: [PATCH 12/20] ci: restore pull_request trigger for Run tests workflow Rebase onto main dropped the pull_request event, so PR heads only got push-triggered runs that could stall behind branch-level concurrency. Restore pull_request while keeping per-SHA concurrency groups. Co-authored-by: Martin DONADIEU --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 901d13b288..9c686e5fc5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ concurrency: cancel-in-progress: ${{ github.event_name != 'pull_request' }} on: + pull_request: workflow_dispatch: push: branches-ignore: From 45b32cb636b27902ff8b5bb1a4176aa9b363c01a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 16:02:21 +0000 Subject: [PATCH 13/20] test(files): clarify keyed cache purge unit test name Rename the purge test so CI and reviewers see checksum-keyed cache variants are part of the deleted-bundle purge contract. Co-authored-by: Martin DONADIEU --- tests/files-deleted-cache.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/files-deleted-cache.unit.test.ts b/tests/files-deleted-cache.unit.test.ts index 6b5d998a49..1c373e5dd4 100644 --- a/tests/files-deleted-cache.unit.test.ts +++ b/tests/files-deleted-cache.unit.test.ts @@ -129,7 +129,7 @@ describe('deleted bundle cache', () => { expect(queryMock).not.toHaveBeenCalled() }) - it('purges file cache keys and writes a deleted marker', async () => { + it('purges path and key=checksum cache entries and writes a deleted marker', async () => { const cache = createCache() globalThis.caches = { default: cache } as any From d05d493dc046004dc384928f29eb2c47e8a42362 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 16:11:05 +0000 Subject: [PATCH 14/20] docs(files): note checksum requirement for cache purge Document why purgeFileReadCache needs the bundle checksum so keyed edge-cache entries are deleted with the path-only cache row. Co-authored-by: Martin DONADIEU --- supabase/functions/_backend/files/file_read_cache.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts index b3c8394512..1077d262e5 100644 --- a/supabase/functions/_backend/files/file_read_cache.ts +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -164,6 +164,7 @@ function buildWorkersFileCacheRequests(fileId: string, checksum?: string | null) ) } +// Checksum is required so key=checksum cache variants are purged alongside path-only entries. export async function purgeFileReadCache(fileId: string, checksum?: string | null): Promise { await markFileDeletedInCache(fileId) From 7eff09e7657319edba5d43198957e49df1eb3cda Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 16:22:20 +0000 Subject: [PATCH 15/20] test(files): mock getDatabaseURL in files edge unit tests Shared read-only pg pool lookup needs getDatabaseURL on every files test that stubs pg.ts; without it deleted-bundle guard fails closed. Co-authored-by: Martin DONADIEU --- tests/files-app-read-guard.unit.test.ts | 1 + tests/files-bandwidth.unit.test.ts | 1 + tests/files-local-read-proxy.unit.test.ts | 1 + tests/files-r2-error.test.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/tests/files-app-read-guard.unit.test.ts b/tests/files-app-read-guard.unit.test.ts index b3aeac928f..d6bc6a8b2d 100644 --- a/tests/files-app-read-guard.unit.test.ts +++ b/tests/files-app-read-guard.unit.test.ts @@ -26,6 +26,7 @@ vi.mock('../supabase/functions/_backend/utils/discord.ts', () => ({ vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ closeClient: () => Promise.resolve(), getAppOwnerPostgres: vi.fn(), + getDatabaseURL: vi.fn(() => 'postgres://test'), getDrizzleClient: vi.fn(() => ({})), getPgClient: getPgClientMock, })) diff --git a/tests/files-bandwidth.unit.test.ts b/tests/files-bandwidth.unit.test.ts index dadd9fb7e1..cccae41d54 100644 --- a/tests/files-bandwidth.unit.test.ts +++ b/tests/files-bandwidth.unit.test.ts @@ -22,6 +22,7 @@ vi.mock('../supabase/functions/_backend/utils/discord.ts', () => ({ vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ closeClient: closeClientMock, getAppOwnerPostgres: vi.fn(), + getDatabaseURL: vi.fn(() => 'postgres://test'), getDrizzleClient: vi.fn(() => ({})), getPgClient: getPgClientMock, })) diff --git a/tests/files-local-read-proxy.unit.test.ts b/tests/files-local-read-proxy.unit.test.ts index c84904c09e..99c1274cd3 100644 --- a/tests/files-local-read-proxy.unit.test.ts +++ b/tests/files-local-read-proxy.unit.test.ts @@ -23,6 +23,7 @@ vi.mock('../supabase/functions/_backend/utils/discord.ts', () => ({ vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ closeClient: () => Promise.resolve(), getAppOwnerPostgres: vi.fn(), + getDatabaseURL: vi.fn(() => 'postgres://test'), getDrizzleClient: vi.fn(() => ({})), getPgClient: getPgClientMock, })) diff --git a/tests/files-r2-error.test.ts b/tests/files-r2-error.test.ts index 182d31f5cb..6ad3e700bf 100644 --- a/tests/files-r2-error.test.ts +++ b/tests/files-r2-error.test.ts @@ -26,6 +26,7 @@ vi.mock('../supabase/functions/_backend/utils/discord.ts', () => ({ vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ closeClient: closeClientMock, getAppOwnerPostgres: vi.fn(), + getDatabaseURL: vi.fn(() => 'postgres://test'), getDrizzleClient: vi.fn(() => ({})), getPgClient: getPgClientMock, })) From 769ccaaffe5dc0ded8e1182fc9d5b24ddae91647 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 16:39:45 +0000 Subject: [PATCH 16/20] fix(files): use primary DB for deleted bundle lookup Deleted-version checks on the files edge path need authoritative app_versions state; read-replica lookups can fail after app delete and returned 500s in CI. Co-authored-by: Martin DONADIEU --- supabase/functions/_backend/files/file_read_cache.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts index 1077d262e5..b02b3146a6 100644 --- a/supabase/functions/_backend/files/file_read_cache.ts +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -122,10 +122,10 @@ export async function markFileDeletedInCache(fileId: string): Promise { let sharedReadOnlyPool: ReturnType | null = null let sharedReadOnlyPoolUrl: string | null = null -function getSharedReadOnlyPgClient(c: Context): ReturnType { - const dbUrl = getDatabaseURL(c, true) +function getSharedDeletedLookupPgClient(c: Context): ReturnType { + const dbUrl = getDatabaseURL(c, false) if (!sharedReadOnlyPool || sharedReadOnlyPoolUrl !== dbUrl) { - sharedReadOnlyPool = getPgClient(c, true) + sharedReadOnlyPool = getPgClient(c, false) sharedReadOnlyPoolUrl = dbUrl } return sharedReadOnlyPool @@ -184,7 +184,7 @@ export async function isAttachmentVersionDeleted(c: Context, fileId: string): Pr return true try { - const pgClient = getSharedReadOnlyPgClient(c) + const pgClient = getSharedDeletedLookupPgClient(c) const result = await pgClient.query<{ deleted: boolean | null, deleted_at: string | null }>( ` SELECT deleted, deleted_at From 7a9604bcc959f93b944b04cfc5a9bb3c5a316988 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 16:56:31 +0000 Subject: [PATCH 17/20] fix(files): scope deleted-bundle guard to zip r2 paths app_versions.r2_path only tracks bundle zip objects. Skip the deleted lookup for other cached attachment uploads so orphan reads after app delete keep working while deleted bundles stay blocked. Co-authored-by: Martin DONADIEU --- .../_backend/files/file_read_cache.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts index b02b3146a6..bb9d7cb7ca 100644 --- a/supabase/functions/_backend/files/file_read_cache.ts +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -119,16 +119,16 @@ export async function markFileDeletedInCache(fileId: string): Promise { })) } -let sharedReadOnlyPool: ReturnType | null = null -let sharedReadOnlyPoolUrl: string | null = null +let sharedDeletedLookupPool: ReturnType | null = null +let sharedDeletedLookupPoolUrl: string | null = null -function getSharedDeletedLookupPgClient(c: Context): ReturnType { +function getDeletedLookupPgClient(c: Context): ReturnType { const dbUrl = getDatabaseURL(c, false) - if (!sharedReadOnlyPool || sharedReadOnlyPoolUrl !== dbUrl) { - sharedReadOnlyPool = getPgClient(c, false) - sharedReadOnlyPoolUrl = dbUrl + if (!sharedDeletedLookupPool || sharedDeletedLookupPoolUrl !== dbUrl) { + sharedDeletedLookupPool = getPgClient(c, false) + sharedDeletedLookupPoolUrl = dbUrl } - return sharedReadOnlyPool + return sharedDeletedLookupPool } function buildFileReadCacheRequestsForPath(fileId: string, checksum?: string | null): Request[] { @@ -183,8 +183,12 @@ export async function isAttachmentVersionDeleted(c: Context, fileId: string): Pr if (await hasDeletedFileMarker(fileId)) return true + // app_versions.r2_path only tracks bundle zip objects, not arbitrary attachment uploads. + if (!fileId.endsWith('.zip')) + return false + try { - const pgClient = getSharedDeletedLookupPgClient(c) + const pgClient = getDeletedLookupPgClient(c) const result = await pgClient.query<{ deleted: boolean | null, deleted_at: string | null }>( ` SELECT deleted, deleted_at From 167b93fded58b25fa7a3b35507596195e4a22dfa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 17:19:53 +0000 Subject: [PATCH 18/20] test(rate-limit): stabilize device DELETE burst; document global DB gate - hitRateLimit: send limit+1 requests per burst instead of time-boxing - Warm channel_self and device endpoints before Cloudflare rate-limit tests - Document primary-DB lookup as the globally durable deletion gate in purgeFileReadCache Co-authored-by: Martin DONADIEU --- .../_backend/files/file_read_cache.ts | 4 +++ tests/channel-rate-limit.test.ts | 34 ++++++++++--------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts index bb9d7cb7ca..cbacd1eff5 100644 --- a/supabase/functions/_backend/files/file_read_cache.ts +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -164,6 +164,10 @@ function buildWorkersFileCacheRequests(fileId: string, checksum?: string | null) ) } +// Per-DC cache delete/marker calls are best-effort accelerators. The globally durable +// deletion gate is isAttachmentVersionDeleted's primary-DB lookup on app_versions.r2_path +// (deleted/deleted_at), which the serve path checks before returning cached bytes or +// restoring to R2. Workers CachedFiles also short-circuits on the local deleted marker. // Checksum is required so key=checksum cache variants are purged alongside path-only entries. export async function purgeFileReadCache(fileId: string, checksum?: string | null): Promise { await markFileDeletedInCache(fileId) diff --git a/tests/channel-rate-limit.test.ts b/tests/channel-rate-limit.test.ts index 97f53ad23c..167ba5967f 100644 --- a/tests/channel-rate-limit.test.ts +++ b/tests/channel-rate-limit.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { env } from 'node:process' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { BASE_URL, getBaseData, headers, PLUGIN_BASE_URL, resetAndSeedAppData, resetAppData, resetAppDataStats } from './test-utils.ts' +import { BASE_URL, getBaseData, headers, PLUGIN_BASE_URL, resetAndSeedAppData, resetAppData, resetAppDataStats, warmEdgeEndpoint } from './test-utils.ts' // Rate limiting uses Cloudflare Workers Cache API, which isn't available in Supabase Edge Functions const USE_CLOUDFLARE = env.USE_CLOUDFLARE_WORKERS === 'true' @@ -54,26 +54,16 @@ function sleep(ms: number): Promise { } // The limiter counts requests in a one-second window anchored to the first request -// (see channelSelfRateLimit.ts) and its cache counter is not atomic under concurrent -// requests, so bursts must be sequential AND finish inside the window to trip it. -// Send sequential requests within the window budget; when a slow runner lets the -// window expire before the limit trips, wait out the counter and retry the round. -const WINDOW_BUDGET_MS = 900 - +// (see channelSelfRateLimit.ts). Send limit+1 sequential requests as fast as the runner +// allows; when a slow endpoint lets the window expire mid-burst, wait out the counter +// and retry with a fresh burst. async function hitRateLimit(makeRequest: (deviceId: string) => Promise, deviceId: string): Promise { - for (let round = 0; round < 4; round++) { - const roundStart = Date.now() - let sent = 0 - while (Date.now() - roundStart < WINDOW_BUDGET_MS) { + for (let round = 0; round < 6; round++) { + for (let sent = 0; sent < OP_LIMIT_PER_SECOND + 1; sent++) { const response = await makeRequest(deviceId) - sent += 1 if (response.status === 429) return response - // Three times the limit landed inside one window without a 429: the limiter is broken. - if (sent >= OP_LIMIT_PER_SECOND * 3) - return null } - // Window expired before the limit could trip; let the counter reset and retry. await sleep(1100) } return null @@ -114,6 +104,18 @@ async function testRateLimitBehavior( beforeAll(async () => { await resetAndSeedAppData(APPNAME) + if (USE_CLOUDFLARE) { + await warmEdgeEndpoint(`${PLUGIN_BASE_URL}/channel_self`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(getBaseData(APPNAME)), + }) + await warmEdgeEndpoint(`${BASE_URL}/device`, { + method: 'DELETE', + headers, + body: JSON.stringify({ app_id: APPNAME, device_id: randomUUID().toLowerCase() }), + }) + } }) afterAll(async () => { From cee6440a1d0b5ca10505bfee68688bde48756b6c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 17:35:21 +0000 Subject: [PATCH 19/20] test(rate-limit): clarify burst retry comment Co-authored-by: Martin DONADIEU --- tests/channel-rate-limit.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/channel-rate-limit.test.ts b/tests/channel-rate-limit.test.ts index 167ba5967f..5b062726f5 100644 --- a/tests/channel-rate-limit.test.ts +++ b/tests/channel-rate-limit.test.ts @@ -59,6 +59,7 @@ function sleep(ms: number): Promise { // and retry with a fresh burst. async function hitRateLimit(makeRequest: (deviceId: string) => Promise, deviceId: string): Promise { for (let round = 0; round < 6; round++) { + // Send limit+1 back-to-back; slow runners may span multiple 1s windows. for (let sent = 0; sent < OP_LIMIT_PER_SECOND + 1; sent++) { const response = await makeRequest(deviceId) if (response.status === 429) From 22843a857d35f0f54d8c04ecbdb18d2e2c40e30a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 19:12:00 +0000 Subject: [PATCH 20/20] docs(files): cross-ref files.ts cache-hit deletion guard Co-authored-by: Martin DONADIEU --- supabase/functions/_backend/files/file_read_cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts index cbacd1eff5..e7e6eed7dd 100644 --- a/supabase/functions/_backend/files/file_read_cache.ts +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -167,7 +167,7 @@ function buildWorkersFileCacheRequests(fileId: string, checksum?: string | null) // Per-DC cache delete/marker calls are best-effort accelerators. The globally durable // deletion gate is isAttachmentVersionDeleted's primary-DB lookup on app_versions.r2_path // (deleted/deleted_at), which the serve path checks before returning cached bytes or -// restoring to R2. Workers CachedFiles also short-circuits on the local deleted marker. +// restoring to R2 (see files.ts cache-hit guard). Workers CachedFiles also short-circuits on the local deleted marker. // Checksum is required so key=checksum cache variants are purged alongside path-only entries. export async function purgeFileReadCache(fileId: string, checksum?: string | null): Promise { await markFileDeletedInCache(fileId)