Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8d22873
fix(security): stop serving and restoring deleted bundle cache
riderx Aug 16, 2026
4bacb1f
fix(security): satisfy deleted-cache typecheck
riderx Aug 16, 2026
bd8d65e
Revert "test(security): accept bundle.delete trigger on upload soft-d…
riderx Aug 16, 2026
1ea43bc
fix(security): harden deleted bundle cache purge and serve paths
cursoragent Aug 25, 2026
4970807
test(files): mock pg client in bandwidth unit tests for deleted guard
cursoragent Aug 25, 2026
9a8a556
fix(test): align upload-only delete assertion with bundle delete guard
cursoragent Aug 26, 2026
19ba5ff
test(channel_self): retry Kong 502/503 and warm plugin endpoint
cursoragent Aug 26, 2026
eaee5d5
fix(files): purge keyed file-read cache entries on bundle delete
cursoragent Aug 26, 2026
eeadc47
test(files): mock getDatabaseURL for shared read-only pool lookup
cursoragent Aug 26, 2026
1802f43
test(files): assert checksum passed to purgeFileReadCache on delete
cursoragent Aug 26, 2026
f74b339
ci: scope push Run tests concurrency by commit SHA
cursoragent Aug 26, 2026
0dea67a
ci: restore pull_request trigger for Run tests workflow
cursoragent Aug 26, 2026
45b32cb
test(files): clarify keyed cache purge unit test name
cursoragent Aug 26, 2026
d05d493
docs(files): note checksum requirement for cache purge
cursoragent Aug 26, 2026
7eff09e
test(files): mock getDatabaseURL in files edge unit tests
cursoragent Aug 26, 2026
769ccaa
fix(files): use primary DB for deleted bundle lookup
cursoragent Aug 26, 2026
7a9604b
fix(files): scope deleted-bundle guard to zip r2 paths
cursoragent Aug 26, 2026
167b93f
test(rate-limit): stabilize device DELETE burst; document global DB gate
cursoragent Aug 26, 2026
cee6440
test(rate-limit): clarify burst retry comment
cursoragent Aug 26, 2026
22843a8
docs(files): cross-ref files.ts cache-hit deletion guard
cursoragent Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ 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) || 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' }}

on:
pull_request:
workflow_dispatch:
push:
branches-ignore:
Expand Down Expand Up @@ -323,13 +325,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
Expand Down Expand Up @@ -489,7 +491,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 }}
Expand Down Expand Up @@ -599,7 +601,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 }}
Expand Down Expand Up @@ -669,7 +671,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 }}
Expand Down Expand Up @@ -741,15 +743,15 @@ 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 }}
SUPABASE_WORKTREE_PORT_OFFSET: ${{ matrix.supabase_port_offset }}
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
Expand Down Expand Up @@ -883,7 +885,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 }}
Expand Down Expand Up @@ -998,7 +1000,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
Expand Down
21 changes: 19 additions & 2 deletions cloudflare_workers/files/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<Response>
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -108,6 +108,23 @@ export const filesWorkerCacheTestUtils = {

export default {
async fetch(request: Request, env: Cloudflare.Env, ctx: FilesExecutionContext): Promise<Response> {
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,
headers: { 'Content-Type': 'application/json' },
})
}

const cacheKey = buildWorkersCacheKey(request)
const cachedFiles = ctx.exports?.CachedFiles
if (cacheKey && cachedFiles)
Expand Down
225 changes: 225 additions & 0 deletions supabase/functions/_backend/files/file_read_cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import type { Context } from 'hono'
import { getRuntimeKey } from 'hono/adapter'
import { cloudlog } from '../utils/logging.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'
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/',
'/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
Comment thread
cursor[bot] marked this conversation as resolved.
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)}`
}

type CacheLike = Cache & {
default?: Cache
open?: (cacheName: string) => Promise<Cache>
}

let fileReadCachePromise: Promise<Cache | null> | null = null

async function resolveFileReadCache(): Promise<Cache | null> {
if (typeof caches === 'undefined')
return null

const cacheStorage = caches as unknown as CacheLike
if (getRuntimeKey() === 'workerd' && cacheStorage.default)
return cacheStorage.default

if (typeof cacheStorage.open === 'function') {
try {
return await cacheStorage.open(FILE_READ_CACHE_NAME)
}
catch {
return null
}
}

return null
}

export async function getFileReadCache(): Promise<Cache | null> {
fileReadCachePromise ??= resolveFileReadCache()
return fileReadCachePromise
}

export async function hasDeletedFileMarker(fileId: string): Promise<boolean> {
const cache = await 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<void> {
const cache = await 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',
},
}))
}

let sharedDeletedLookupPool: ReturnType<typeof getPgClient> | null = null
let sharedDeletedLookupPoolUrl: string | null = null

function getDeletedLookupPgClient(c: Context): ReturnType<typeof getPgClient> {
const dbUrl = getDatabaseURL(c, false)
if (!sharedDeletedLookupPool || sharedDeletedLookupPoolUrl !== dbUrl) {
sharedDeletedLookupPool = getPgClient(c, false)
sharedDeletedLookupPoolUrl = dbUrl
}
return sharedDeletedLookupPool
}

function buildFileReadCacheRequestsForPath(fileId: string, checksum?: string | null): Request[] {
const identityParamSets: Array<Record<string, string>> = [{}]
if (checksum)
identityParamSets.push({ key: checksum })

return FILE_READ_CACHE_ORIGINS.flatMap(origin =>
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, 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/'))
.flatMap(prefix =>
searchVariants.map(search =>
new Request(`${DELETED_FILE_MARKER_ORIGIN}${buildWorkersFileCacheKey(`${prefix}${fileId}`, search)}`),

Check warning on line 162 in supabase/functions/_backend/files/file_read_cache.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AaA-oQ3JbnRR-wKuUebn&open=AaA-oQ3JbnRR-wKuUebn&pullRequest=3100
),
)
}

// 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 (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<void> {
await markFileDeletedInCache(fileId)

const cache = await getFileReadCache()
if (!cache || typeof cache.delete !== 'function')
return

const requests = [
...buildFileReadCacheRequestsForPath(fileId, checksum),
...buildWorkersFileCacheRequests(fileId, checksum),
]
await Promise.all(requests.map(request => cache.delete(request).catch(() => false)))
Comment thread
cursor[bot] marked this conversation as resolved.
}

export async function isAttachmentVersionDeleted(c: Context, fileId: string): Promise<boolean> {
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 = getDeletedLookupPgClient(c)
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 closed',
fileId,
error: error instanceof Error ? error.message : String(error),
})
return true
}
}

export const fileReadCacheTestUtils = {
buildFileReadCacheRequest,
buildDeletedFileMarkerRequest,
buildWorkersFileCacheKey,
getAttachmentFileIdFromReadPath,
isVersionDeleted,
}
Loading
Loading