From 962946c4cd0796fcc424848b37d0770007725737 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:51:20 +0100 Subject: [PATCH 1/2] fix: update edge cache route rules Ports the cache rules onto the current proxy routes and keeps sensitive/error responses no-store. --- nuxt.config.ts | 152 ++++++++++++++++++++- server/plugins/cache-error-responses.ts | 57 ++++++++ tests/server/cache-error-responses.test.ts | 64 +++++++++ 3 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 server/plugins/cache-error-responses.ts create mode 100644 tests/server/cache-error-responses.test.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index fc163886..4b8956ec 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -222,11 +222,153 @@ export default defineNuxtConfig({ 'Cloudflare-CDN-Cache-Control': 'public, max-age=31536000, immutable', }, }, - // HTML and API responses must not be cached by browsers or CDNs — stale - // HTML referencing previous-build chunk hashes is the primary cause of - // "Failed to fetch dynamically imported module" errors after deploys. - // CDN-Cache-Control is honoured by compliant CDNs and overrides any - // edge-side cache rules that may ignore the origin Cache-Control. + + // Static public assets. Safe to cache aggressively now that the + // deployment pipeline retains old `_nuxt/*` chunks in S3 across + // deploys — the chunk-404 risk that originally motivated the + // blanket `no-store` no longer applies to non-HTML routes. + // Fonts are pinned to specific content via the `?v=` query string + // in assets/styles/fonts.scss, so 1y immutable is safe. + '/fonts/**': { + headers: { + 'Cache-Control': 'public, max-age=31536000, immutable', + 'CDN-Cache-Control': 'public, max-age=31536000, immutable', + 'Cloudflare-CDN-Cache-Control': 'public, max-age=31536000, immutable', + }, + }, + // App metadata images are referenced by wallet/connect metadata and + // are stable between releases. + '/logo.svg': { + headers: { + 'Cache-Control': 'public, max-age=86400', + 'CDN-Cache-Control': 'public, max-age=604800', + 'Cloudflare-CDN-Cache-Control': 'public, max-age=604800', + }, + }, + '/logo.png': { + headers: { + 'Cache-Control': 'public, max-age=86400', + 'CDN-Cache-Control': 'public, max-age=604800', + 'Cloudflare-CDN-Cache-Control': 'public, max-age=604800', + }, + }, + '/manifest-img.png': { + headers: { + 'Cache-Control': 'public, max-age=86400', + 'CDN-Cache-Control': 'public, max-age=604800', + 'Cloudflare-CDN-Cache-Control': 'public, max-age=604800', + }, + }, + // Icon assets without content-hashing: shorter browser cache so a + // forced reload re-fetches, longer edge cache because assets change + // very rarely and per-byte egress dominates. + '/entities/**': { + headers: { + 'Cache-Control': 'public, max-age=86400', + 'CDN-Cache-Control': 'public, max-age=604800', + 'Cloudflare-CDN-Cache-Control': 'public, max-age=604800', + }, + }, + '/oracles/**': { + headers: { + 'Cache-Control': 'public, max-age=86400', + 'CDN-Cache-Control': 'public, max-age=604800', + 'Cloudflare-CDN-Cache-Control': 'public, max-age=604800', + }, + }, + '/favicons/**': { + headers: { + 'Cache-Control': 'public, max-age=86400', + 'CDN-Cache-Control': 'public, max-age=604800', + 'Cloudflare-CDN-Cache-Control': 'public, max-age=604800', + }, + }, + '/sounds/**': { + headers: { + 'Cache-Control': 'public, max-age=86400', + 'CDN-Cache-Control': 'public, max-age=604800', + 'Cloudflare-CDN-Cache-Control': 'public, max-age=604800', + }, + }, + + // SWR-friendly API endpoints. Each handler already sets the browser + // Cache-Control policy. The catch-all below would otherwise stamp + // CDN-Cache-Control: no-store, so these explicit rules let Cloudflare + // collapse the same public payload across users while handlers still + // drive browser caching. + '/api/vaults': { + headers: { + 'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + }, + }, + '/api/public/**': { + headers: { + 'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + }, + }, + '/api/labels/**': { + headers: { + 'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + }, + }, + '/api/euler-chains': { + headers: { + 'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + }, + }, + '/api/proxy/merkl/opportunities': { + headers: { + 'CDN-Cache-Control': 'public, s-maxage=60, stale-while-revalidate=60', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=60, stale-while-revalidate=60', + }, + }, + '/api/proxy/fuul/incentives': { + headers: { + 'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + }, + }, + '/api/proxy/incentra/sdk/v1/eulerCampaigns': { + headers: { + 'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + }, + }, + '/api/proxy/intrinsic-apy-overrides': { + headers: { + 'CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + }, + }, + // Token lists change infrequently — 5 min edge cache, 10 min SWR. + '/api/token-list': { + headers: { + 'CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + }, + }, + // TOS changes very rarely — cache aggressively. Browser gets a + // 5 min window; edge can serve for an hour and SWR for a day. + '/api/tos': { + headers: { + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', + 'CDN-Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + 'Cloudflare-CDN-Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + }, + }, + + // HTML and everything else: must not be cached by browsers or CDNs. + // S3 retention of old chunks mitigates the original stale-HTML + // failure mode for non-HTML routes, but HTML itself must always + // resolve to the latest build's chunk hashes. Sensitive/live APIs + // such as /api/pyth/updates, /api/proxy/subgraph/*, + // /api/proxy/turtle/*, user-specific reward/proof proxy paths, + // /api/rpc/*, /api/tenderly/*, and /api/screen-address also stay + // on this strict fallback. '/**': { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate', diff --git a/server/plugins/cache-error-responses.ts b/server/plugins/cache-error-responses.ts new file mode 100644 index 00000000..f238dff8 --- /dev/null +++ b/server/plugins/cache-error-responses.ts @@ -0,0 +1,57 @@ +import type { H3Event } from 'h3' +import { getRequestURL, setResponseHeader } from 'h3' + +const NO_STORE_CACHE_CONTROL = 'no-store' +const USER_REWARD_PATH_RE = /^\/api\/proxy\/merkl\/users\/0x[a-fA-F0-9]{40}\/rewards\/?$/ + +const alwaysNoStorePrefixes = [ + '/api/proxy/subgraph/', + '/api/proxy/turtle/', + '/api/rpc/', + '/api/tenderly/', +] as const + +const alwaysNoStorePaths = new Set([ + '/api/pyth/updates', + '/api/proxy/fuul/claimable-rewards', + '/api/proxy/incentra/v1/getMerkleProofsBatch', + '/api/screen-address', +]) + +export function forceNoStoreCacheHeaders(event: H3Event): void { + setResponseHeader(event, 'Cache-Control', NO_STORE_CACHE_CONTROL) + setResponseHeader(event, 'CDN-Cache-Control', NO_STORE_CACHE_CONTROL) + setResponseHeader(event, 'Cloudflare-CDN-Cache-Control', NO_STORE_CACHE_CONTROL) +} + +export function shouldForceNoStoreForPath(pathname: string): boolean { + if (alwaysNoStorePaths.has(pathname)) return true + if (USER_REWARD_PATH_RE.test(pathname)) return true + return alwaysNoStorePrefixes.some(prefix => pathname.startsWith(prefix)) +} + +export function forceNoStoreForErrorResponse(event: H3Event): void { + const statusCode = event.node.res.statusCode + if (statusCode < 400) return + + forceNoStoreCacheHeaders(event) +} + +export function forceNoStoreForSensitivePath(event: H3Event): void { + const { pathname } = getRequestURL(event) + if (!shouldForceNoStoreForPath(pathname)) return + + forceNoStoreCacheHeaders(event) +} + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('error', (_error, context) => { + if (context.event) { + forceNoStoreCacheHeaders(context.event) + } + }) + nitroApp.hooks.hook('beforeResponse', (event) => { + forceNoStoreForSensitivePath(event) + forceNoStoreForErrorResponse(event) + }) +}) diff --git a/tests/server/cache-error-responses.test.ts b/tests/server/cache-error-responses.test.ts new file mode 100644 index 00000000..d764ecaf --- /dev/null +++ b/tests/server/cache-error-responses.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import type { H3Event } from 'h3' +import { + forceNoStoreForErrorResponse, + shouldForceNoStoreForPath, +} from '~/server/plugins/cache-error-responses' + +function createMockEvent(statusCode: number) { + const headers: Record = {} + return { + event: { + node: { + res: { + statusCode, + setHeader: (name: string, value: string) => { + headers[name] = value + }, + }, + }, + } as unknown as H3Event, + headers, + } +} + +describe('forceNoStoreForErrorResponse', () => { + it('overrides route-rule cache headers on error responses', () => { + const { event, headers } = createMockEvent(451) + + forceNoStoreForErrorResponse(event) + + expect(headers['Cache-Control']).toBe('no-store') + expect(headers['CDN-Cache-Control']).toBe('no-store') + expect(headers['Cloudflare-CDN-Cache-Control']).toBe('no-store') + }) + + it('leaves successful responses untouched', () => { + const { event, headers } = createMockEvent(200) + + forceNoStoreForErrorResponse(event) + + expect(headers).toEqual({}) + }) +}) + +describe('shouldForceNoStoreForPath', () => { + it.each([ + '/api/proxy/fuul/claimable-rewards', + '/api/proxy/incentra/v1/getMerkleProofsBatch', + '/api/proxy/merkl/users/0x0000000000000000000000000000000000000000/rewards', + '/api/proxy/subgraph/1', + '/api/proxy/turtle/streams/merkle_proofs', + ])('forces no-store for sensitive path %s', (pathname) => { + expect(shouldForceNoStoreForPath(pathname)).toBe(true) + }) + + it.each([ + '/api/proxy/merkl/opportunities', + '/api/proxy/fuul/incentives', + '/api/proxy/incentra/sdk/v1/eulerCampaigns', + '/api/proxy/intrinsic-apy-overrides', + ])('leaves public cache path %s to route rules', (pathname) => { + expect(shouldForceNoStoreForPath(pathname)).toBe(false) + }) +}) From a171e36580226d8268c965e3aaa228f37e480011 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:21:20 +0100 Subject: [PATCH 2/2] fix: split cache no-store guards Separates error response cache protection from sensitive route cache protection and normalizes sensitive paths before matching. --- server/plugins/cache-error-responses.ts | 39 +------------------ server/plugins/sensitive-route-cache.ts | 45 ++++++++++++++++++++++ server/utils/cache-headers.ts | 10 +++++ tests/server/cache-error-responses.test.ts | 26 +------------ tests/server/sensitive-route-cache.test.ts | 28 ++++++++++++++ 5 files changed, 85 insertions(+), 63 deletions(-) create mode 100644 server/plugins/sensitive-route-cache.ts create mode 100644 server/utils/cache-headers.ts create mode 100644 tests/server/sensitive-route-cache.test.ts diff --git a/server/plugins/cache-error-responses.ts b/server/plugins/cache-error-responses.ts index f238dff8..bcf25e12 100644 --- a/server/plugins/cache-error-responses.ts +++ b/server/plugins/cache-error-responses.ts @@ -1,34 +1,5 @@ import type { H3Event } from 'h3' -import { getRequestURL, setResponseHeader } from 'h3' - -const NO_STORE_CACHE_CONTROL = 'no-store' -const USER_REWARD_PATH_RE = /^\/api\/proxy\/merkl\/users\/0x[a-fA-F0-9]{40}\/rewards\/?$/ - -const alwaysNoStorePrefixes = [ - '/api/proxy/subgraph/', - '/api/proxy/turtle/', - '/api/rpc/', - '/api/tenderly/', -] as const - -const alwaysNoStorePaths = new Set([ - '/api/pyth/updates', - '/api/proxy/fuul/claimable-rewards', - '/api/proxy/incentra/v1/getMerkleProofsBatch', - '/api/screen-address', -]) - -export function forceNoStoreCacheHeaders(event: H3Event): void { - setResponseHeader(event, 'Cache-Control', NO_STORE_CACHE_CONTROL) - setResponseHeader(event, 'CDN-Cache-Control', NO_STORE_CACHE_CONTROL) - setResponseHeader(event, 'Cloudflare-CDN-Cache-Control', NO_STORE_CACHE_CONTROL) -} - -export function shouldForceNoStoreForPath(pathname: string): boolean { - if (alwaysNoStorePaths.has(pathname)) return true - if (USER_REWARD_PATH_RE.test(pathname)) return true - return alwaysNoStorePrefixes.some(prefix => pathname.startsWith(prefix)) -} +import { forceNoStoreCacheHeaders } from '~/server/utils/cache-headers' export function forceNoStoreForErrorResponse(event: H3Event): void { const statusCode = event.node.res.statusCode @@ -37,13 +8,6 @@ export function forceNoStoreForErrorResponse(event: H3Event): void { forceNoStoreCacheHeaders(event) } -export function forceNoStoreForSensitivePath(event: H3Event): void { - const { pathname } = getRequestURL(event) - if (!shouldForceNoStoreForPath(pathname)) return - - forceNoStoreCacheHeaders(event) -} - export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('error', (_error, context) => { if (context.event) { @@ -51,7 +15,6 @@ export default defineNitroPlugin((nitroApp) => { } }) nitroApp.hooks.hook('beforeResponse', (event) => { - forceNoStoreForSensitivePath(event) forceNoStoreForErrorResponse(event) }) }) diff --git a/server/plugins/sensitive-route-cache.ts b/server/plugins/sensitive-route-cache.ts new file mode 100644 index 00000000..6b120810 --- /dev/null +++ b/server/plugins/sensitive-route-cache.ts @@ -0,0 +1,45 @@ +import type { H3Event } from 'h3' +import { getRequestURL } from 'h3' +import { forceNoStoreCacheHeaders } from '~/server/utils/cache-headers' + +const USER_REWARD_PATH_RE = /^\/api\/proxy\/merkl\/users\/0x[a-fA-F0-9]{40}\/rewards$/ + +const alwaysNoStorePrefixes = [ + '/api/proxy/subgraph', + '/api/proxy/turtle', + '/api/rpc', + '/api/tenderly', +] as const + +const alwaysNoStorePaths = new Set([ + '/api/pyth/updates', + '/api/proxy/fuul/claimable-rewards', + '/api/proxy/incentra/v1/getMerkleProofsBatch', + '/api/screen-address', +]) + +const normalizePathname = (pathname: string): string => + pathname === '/' ? pathname : pathname.replace(/\/+$/, '') + +const isPathInPrefix = (pathname: string, prefix: string): boolean => + pathname === prefix || pathname.startsWith(`${prefix}/`) + +export function shouldForceNoStoreForPath(pathname: string): boolean { + const normalizedPathname = normalizePathname(pathname) + if (alwaysNoStorePaths.has(normalizedPathname)) return true + if (USER_REWARD_PATH_RE.test(normalizedPathname)) return true + return alwaysNoStorePrefixes.some(prefix => isPathInPrefix(normalizedPathname, prefix)) +} + +export function forceNoStoreForSensitivePath(event: H3Event): void { + const { pathname } = getRequestURL(event) + if (!shouldForceNoStoreForPath(pathname)) return + + forceNoStoreCacheHeaders(event) +} + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('beforeResponse', (event) => { + forceNoStoreForSensitivePath(event) + }) +}) diff --git a/server/utils/cache-headers.ts b/server/utils/cache-headers.ts new file mode 100644 index 00000000..91e3d7c5 --- /dev/null +++ b/server/utils/cache-headers.ts @@ -0,0 +1,10 @@ +import type { H3Event } from 'h3' +import { setResponseHeader } from 'h3' + +const NO_STORE_CACHE_CONTROL = 'no-store' + +export function forceNoStoreCacheHeaders(event: H3Event): void { + setResponseHeader(event, 'Cache-Control', NO_STORE_CACHE_CONTROL) + setResponseHeader(event, 'CDN-Cache-Control', NO_STORE_CACHE_CONTROL) + setResponseHeader(event, 'Cloudflare-CDN-Cache-Control', NO_STORE_CACHE_CONTROL) +} diff --git a/tests/server/cache-error-responses.test.ts b/tests/server/cache-error-responses.test.ts index d764ecaf..124c0a54 100644 --- a/tests/server/cache-error-responses.test.ts +++ b/tests/server/cache-error-responses.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import type { H3Event } from 'h3' -import { - forceNoStoreForErrorResponse, - shouldForceNoStoreForPath, -} from '~/server/plugins/cache-error-responses' +import { forceNoStoreForErrorResponse } from '~/server/plugins/cache-error-responses' function createMockEvent(statusCode: number) { const headers: Record = {} @@ -41,24 +38,3 @@ describe('forceNoStoreForErrorResponse', () => { expect(headers).toEqual({}) }) }) - -describe('shouldForceNoStoreForPath', () => { - it.each([ - '/api/proxy/fuul/claimable-rewards', - '/api/proxy/incentra/v1/getMerkleProofsBatch', - '/api/proxy/merkl/users/0x0000000000000000000000000000000000000000/rewards', - '/api/proxy/subgraph/1', - '/api/proxy/turtle/streams/merkle_proofs', - ])('forces no-store for sensitive path %s', (pathname) => { - expect(shouldForceNoStoreForPath(pathname)).toBe(true) - }) - - it.each([ - '/api/proxy/merkl/opportunities', - '/api/proxy/fuul/incentives', - '/api/proxy/incentra/sdk/v1/eulerCampaigns', - '/api/proxy/intrinsic-apy-overrides', - ])('leaves public cache path %s to route rules', (pathname) => { - expect(shouldForceNoStoreForPath(pathname)).toBe(false) - }) -}) diff --git a/tests/server/sensitive-route-cache.test.ts b/tests/server/sensitive-route-cache.test.ts new file mode 100644 index 00000000..f8de7209 --- /dev/null +++ b/tests/server/sensitive-route-cache.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { shouldForceNoStoreForPath } from '~/server/plugins/sensitive-route-cache' + +describe('shouldForceNoStoreForPath', () => { + it.each([ + '/api/proxy/fuul/claimable-rewards', + '/api/proxy/fuul/claimable-rewards/', + '/api/proxy/incentra/v1/getMerkleProofsBatch', + '/api/proxy/incentra/v1/getMerkleProofsBatch/', + '/api/proxy/merkl/users/0x0000000000000000000000000000000000000000/rewards', + '/api/proxy/merkl/users/0x0000000000000000000000000000000000000000/rewards/', + '/api/proxy/subgraph/1', + '/api/proxy/subgraph/1/', + '/api/proxy/turtle/streams/merkle_proofs', + '/api/proxy/turtle/streams/merkle_proofs/', + ])('forces no-store for sensitive path %s', (pathname) => { + expect(shouldForceNoStoreForPath(pathname)).toBe(true) + }) + + it.each([ + '/api/proxy/merkl/opportunities', + '/api/proxy/fuul/incentives', + '/api/proxy/incentra/sdk/v1/eulerCampaigns', + '/api/proxy/intrinsic-apy-overrides', + ])('leaves public cache path %s to route rules', (pathname) => { + expect(shouldForceNoStoreForPath(pathname)).toBe(false) + }) +})