Skip to content

feat(cache): cache de CDN para /_serverFn + correções de rota privada e bypass - #510

Closed
JonasJesus42 wants to merge 5 commits into
mainfrom
no-store-html-serverfn-tanstack
Closed

feat(cache): cache de CDN para /_serverFn + correções de rota privada e bypass#510
JonasJesus42 wants to merge 5 commits into
mainfrom
no-store-html-serverfn-tanstack

Conversation

@JonasJesus42

@JonasJesus42 JonasJesus42 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Contexto

Todos os sites TanStack servem HTML e /_serverFn com Cdn-Cache-Control: no-store, logo
Cf-Cache-Status: BYPASS: nenhuma requisição é servida pelo CDN, todas invocam o Worker.

O no-store está correto no caso geral. O Worker chaveia o cache por uma Request sintética que carrega
__seg/__cf_device/__cf_geo/__bot/__fetch/__abf (buildCacheKey), enquanto o CDN da Cloudflare
chaveia pela URL crua e ignora Vary além de Accept-Encoding. Deixar o CDN cachear por URL serviria HTML
de desktop para mobile, de uma região para outra, ou o render eager de um crawler para um humano.

Este PR faz três coisas: libera o CDN para /_serverFn (onde dá para fazer com segurança), fecha buracos de
classificação de rota que o no-store vinha mascarando, e deixa pronto — inativo — o material para
cachear HTML no CDN.

O que muda

1. /_serverFn no CDN (opt-in)

Inverte o problema: em vez de esperar que o CDN reproduza a chave, o cliente põe o segmento na URL
(?__cseg=<device>.<buildHash>), o que torna a chave do CDN equivalente à do Worker.

// worker-entry.ts
createDecoWorkerEntry(serverEntry, {
  cdnCacheControl: "serverfn-segment",
  buildSegment: (request) => ({ /* ... */ }),   // obrigatório
});
// src/start.ts
import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch";
export const startInstance = createStart(() => ({ serverFns: { fetch: decoServerFnFetch } }));

Default inalterado. O marcador é dica, não fonte de verdade — o Worker recalcula o segmento do próprio
request e só relaxa o no-store quando confere exatamente.

Portado de uma implementação que já roda em produção num site, com dois gaps fechados: aquela versão
verificava só o device, deixando bots (que executam JS, como Lighthouse/PageSpeed) e cohorts de A/B
envenenarem uma entrada compartilhada no CDN.

2. Endurecimento (independe do opt-in)

Estes valem mesmo sem ligar nada, e alguns são bugs reais em produção hoje:

  • PRIVATE_PREFIX_RE tinha buracos graves. Era case-sensitive, ancorada na raiz e cobria uma lista curta,
    então /listadedesejos, /wishlist, /favoritos, /orders, /profile, /logout, /cadastro e rotas de
    troca/devolução caíam no default listingpúblico, 120s de edge. Idem /Checkout e /pt/checkout.
  • hasOnlySafeCookies era fail-open: resposta com set-cookie cujos nomes não foram parseados era
    tratada como segura, isto é, cacheável.
  • bypassPaths substituía os defaults em vez de somar, então um site que acrescentava um path perdia
    silenciosamente /deco/, /live/ e /.decofile.
  • Todo bypass agora emite CDN-Cache-Control: no-store, garantido no ponto único de saída em vez de em
    doze call sites. Alguns deletavam o header, outros não, e vários ainda emitiam o Cache-Control público do
    perfil (public, s-maxage=900) na saída.
  • Warn no boot quando falta buildSegment — sem ele o bypass de logado é inerte e usuário autenticado
    compartilha a entrada anônima.
  • match-profile virou armadilha silenciosa: o JSDoc prometia segurança "sem buildSegment e
    deviceSpecificKeys: false", mas deviceSpecificKeys é true por default, então a condição é falsa em todo
    site. Agora é recusado com aviso em vez de cross-servir segmentos.

3. Configuração pelo site: livre para apertar, ruidosa para afrouxar

Como o Worker é a fonte de verdade da elegibilidade, um site pode marcar rotas como privadas à vontade e isso
chega ao CDN sozinho. Afrouxar é o que vaza dado, então ficou deliberadamente incômodo:

registerPrivatePaths(["/listadedesejos", "/trocas"]);   // só restringe; caminho seguro
  • registerCachePattern continua vencendo os builtins, exceto que não consegue mais tornar pública uma
    rota privada (um pattern amplo do site capturava /checkout).
  • setCacheProfile("private", { isPublic: true }) é recusado com aviso; allowPublicPrivateProfile() é a saída,
    e ela tem um nome que você precisa digitar.

4. Material para HTML no CDN — presente, não ativado

blocks-cli/scripts/cdn-rules.ts gera as Cache Rules derivadas das mesmas constantes do Worker
(SEGMENT_COOKIE, BOT_UA_SUBSTRINGS, DECO_MATCHERS_OVERRIDE_PARAM) — duas cópias mantidas à mão divergem,
e o modo de falhar é servir a resposta de um visitante para outro. Os testes fixam essa correspondência.

As regras não listam rotas privadas de propósito: o Worker já manda no-store nelas e a Cloudflare
respeita, então registerPrivatePaths() propaga sem mexer em regra.

POST /_cache/purge passa a purgar o CDN por hostname quando DECO_CF_ZONE_ID + DECO_CF_PURGE_TOKEN
existirem; inerte sem elas.

Validação

Testado end-to-end contra um site real buildado (não só unit): pacotes linkados, bun run build, worker no
wrangler dev, e navegação SPA dirigida por Chrome.

caso Cdn-Cache-Control
GET /_serverFn com marcador correto (desktop e mobile) public, max-age=1800
sem marcador no-store
marcador de outro device (forjado) no-store
build antigo no-store
bot UA no-store
cookie de login no-store
cookie A/B no-store
documento HTML no-store

O valor liberado bate exatamente com o que a implementação de site produz em produção. No site, isso
substituiu ~230 linhas de shim por 6 linhas + um start.ts de 9.

Fase 0 conferida no worker real: /listadedesejos, /Checkout e /pt/minha-conta agora saem
private, no-store; antes eram listing público de 120s.

2046 testes passando, 63 novos. Typecheck limpo nos três pacotes.

As 2 falhas em draft preview (workerEntry.test.ts) e as 2 em nextjs/draftShell.test.ts são
pré-existentes nesta branch — confirmei com git stash que ocorrem idênticas sem estas mudanças.

O que NÃO está aqui

Cachear HTML no CDN. Não é mudança de header: exige aplicar as Cache Rules na zona, e falta validar que
cf.hostname.metadata funciona dentro de Cache Rules (a doc da Cloudflare confirma o campo nos motores de
regras, mas não afirma suporte em Cache Rules especificamente). O ganho ali também é mais estreito do que
parece — o Cache API do Worker já responde do mesmo edge; o CDN economizaria a invocação do Worker, não a
latência de rede. Vale medir custo de invocação antes de decidir.

🤖 Generated with Claude Code


Summary by cubic

Enables opt-in CDN caching for /_serverFn requests: the client attaches a device-plus-build segment to the URL, making the CDN's cache key match the Worker's. Sites keep no-store by default, the Worker re-verifies the marker and falls back on any mismatch (forged, stale, bot, logged-in, A/B), and HTML documents stay uncacheable — the initial navigation has no client hook.

Cache hardening

  • PRIVATE_PREFIX_RE is now case-insensitive, matches behind a locale prefix, and covers wishlist, profile, orders, signup, logout, and returns — routes that previously fell through to the public listing profile.
  • hasOnlySafeCookies fails closed: unparseable set-cookie names no longer count as safe.
  • bypassPaths now always extends framework defaults instead of replacing them.
  • The single response exit stamps no-store on every bypass and on any response missing the header (early returns), so nothing accidental goes public.
  • match-profile is refused with a warning unless the cache key is exactly the raw URL.
  • Non-public profiles can no longer be flipped public, a custom pattern cannot publicize a private path, and registerPrivatePaths() is the new safe way to add private routes.
  • The VTEX middleware stops overwriting Cache-Control on every response and clears CDN-Cache-Control for logged-in or custom-pricing requests.

CDN rules and purge

  • blocks-cli/scripts/cdn-rules.ts generates Cloudflare Cache Rules derived from the Worker's own constants (SEGMENT_COOKIE, BOT_UA_SUBSTRINGS, DECO_MATCHERS_OVERRIDE_PARAM) so the two cannot drift.
  • The bypass and cache rules are mutually exclusive by expression, not order — Cloudflare is last-match-wins, and this removes the ordering trap.
  • The sec-fetch-dest: empty bypass carves out /_serverFn, which is exactly the traffic this feature exists to cache.
  • Rules apply only to hostnames opting in via deco_cdn_html metadata and skip private paths, which the Worker's no-store already covers.
  • POST /_cache/purge also purges the CDN by hostname when DECO_CF_ZONE_ID and DECO_CF_PURGE_TOKEN are set.
  • Boot warns when buildSegment is missing, since the logged-in bypass is inert without it.

Written for commit 8b53a3f. Summary will update on new commits.

Review in cubic

JonasJesus42 and others added 4 commits August 27, 2026 17:41
Every response currently ships `CDN-Cache-Control: no-store`, which hides a
few real gaps in how routes are classified. They are harmless only while
nothing is cached at the CDN; enabling that turns each one into a leak.

- `PRIVATE_PREFIX_RE` only matched a short list, was case-sensitive and
  anchored at the root, so `/listadedesejos`, `/wishlist`, `/favoritos`,
  `/orders`, `/profile`, `/logout`, `/cadastro` and returns routes all fell
  through to the cacheable `listing` default (public, 120s edge) — as did
  `/Checkout` and `/pt/checkout`. Rebuilt from a `PRIVATE_SEGMENTS` list,
  case-insensitive, tolerating a locale prefix.

- `setCacheProfile` would happily flip `private`/`cart`/`none` to public via a
  props bag. Now refused with a warning unless `allowPublicPrivateProfile()`
  is called first — the escape hatch has a name you have to type.

- `registerCachePattern` is evaluated before the built-ins "so they can
  override defaults", which let a broad site pattern capture `/checkout` and
  make it public. Custom patterns can still tighten anything; they can no
  longer make a private path public.

- Adds `registerPrivatePaths()`, the safe half of cache configuration: it can
  only restrict, and (because the Worker is the source of truth for
  cacheability) it propagates to the CDN with no rule change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ypasses

## Why /_serverFn couldn't be CDN-cached

The Worker keys its edge cache on a synthetic Request carrying
`__seg`/`__cf_device`/`__cf_geo`/`__bot`/`__fetch`/`__abf` (`buildCacheKey`),
while Cloudflare's CDN keys on the raw URL and ignores `Vary` beyond
`Accept-Encoding`. Hence the blanket `CDN-Cache-Control: no-store`, and hence
100% of traffic returning `cf-cache-status: BYPASS`.

The fix inverts the problem: the client puts the segment in the URL
(`?__cseg=<device>.<buildHash>`, via `decoServerFnFetch`), which makes the
CDN's key equivalent to the Worker's. Opt in with
`cdnCacheControl: "serverfn-segment"`; the default is unchanged.

Fail-closed on every axis — absent, diverging, forged or stale-build marker,
logged-in / region / sales-channel, an unknown custom segment field, a bot UA
or an A/B cohort cookie all keep today's `no-store`. Worst case is not
caching, never a wrong response. `isBot` and the A/B cookie are checked
against the same helpers `buildCacheKey` uses, so keying and releasing cannot
drift. HTML documents stay `no-store`: the initial navigation is a browser
request with no hook to attach a marker.

Ported from a working site-level implementation, with two gaps closed — that
version verified only the device, leaving bots and A/B cohorts able to poison
a shared CDN entry.

## Hardening (independent of the opt-in)

- Every bypass now emits `CDN-Cache-Control: no-store`, enforced at the single
  response exit rather than at twelve call sites. Some deleted the header,
  some didn't, and several still emitted the profile's public `Cache-Control`
  (`public, s-maxage=900`) on the way out.
- `hasOnlySafeCookies` was fail-open: a response WITH a `set-cookie` whose
  names failed to parse was treated as safe, i.e. cacheable. Now fail-closed.
- `bypassPaths` replaced the framework defaults instead of extending them, so
  a site adding one path silently lost `/deco/`, `/live/` and `/.decofile`.
- Warns at boot when `buildSegment` is missing, since the logged-in bypass is
  inert without it and authenticated visitors share the anonymous entry.
- `cdnCacheControl: "match-profile"` promised safety "without buildSegment and
  deviceSpecificKeys: false", but `deviceSpecificKeys` defaults to true, so
  the condition is false on every site. It is now refused with a warning
  instead of silently cross-serving segments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…response

The VTEX app middleware wraps `handleRequest`, so it runs after the entire
edge-cache layer and is the last writer of `Cache-Control` — including on a
cache HIT. It overwrote unconditionally, which downgraded a home page the
cache layer had resolved as `s-maxage=900` to `vtexCacheControl`'s generic
`s-maxage=60`, throwing away the per-profile TTL.

It now only speaks up for the case it actually knows better about — a
logged-in or custom-pricing request — and when it does, it clears
`CDN-Cache-Control` too. Otherwise a response could go out as
`Cache-Control: private, no-store` alongside `CDN-Cache-Control: public,
max-age=300`, and Cloudflare gives the CDN header precedence. Same pairing the
Worker's own bypasses and `utils/proxy.ts` already use.

Exports `vtexMiddleware` so the behaviour is testable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tants

Caching HTML at the CDN is not a header change: the zone has to reproduce the
parts of the cache key the URL doesn't carry. This adds the generator for
those rules, plus docs. Nothing here is active — applying the ruleset is a
deliberate, separate step, and the rules are scoped to hostnames that opt in
via `deco_cdn_html` custom metadata (sites are custom hostnames in one shared
zone, so an unscoped rule would enable every site at once).

The rules are DERIVED from the same constants the Worker uses (`SEGMENT_COOKIE`,
`BOT_UA_SUBSTRINGS`, `DECO_MATCHERS_OVERRIDE_PARAM`) because two
hand-maintained copies of that list drift, and the failure mode is one visitor
being served another's response. Tests assert exactly that correspondence.

Note what the rules deliberately do NOT contain: private paths. The Worker
already emits `no-store` for them and Cloudflare honours it, so a site calling
`registerPrivatePaths([...])` propagates to the CDN with no rule change.

Also exports `BOT_UA_SUBSTRINGS` (previously inlined in a regex) so the bot
list has a single definition, and teaches `POST /_cache/purge` to purge the
CDN by hostname when `DECO_CF_ZONE_ID` + `DECO_CF_PURGE_TOKEN` are present —
inert without them, since `caches.default` is then the only copy anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JonasJesus42
JonasJesus42 requested a review from a team August 27, 2026 20:43
…eview gaps

Follow-up to the security review of this PR. The first item is a real bug I
introduced, and the shape of it is the worst kind: silent, and certified by a
test that encoded the same wrong assumption.

## Cache Rules were relying on first-match-wins, which Cloudflare is not

The cache phase is LAST-match-wins for non-terminating actions like
`set_cache_settings`. The catch-all `cache: true` rule was a strict superset of
the `cache: false` bypass rule, so it overrode it for every request — making the
entire bypass list inert, including the auth-cookie clause the file itself calls
"the single most important clause". A logged-in visitor would have been served
the shared anonymous entry with the Worker never running, which is precisely the
case the rules exist to prevent.

Both rules are now scoped by expression (`and not (<clauses>)`), so correctness
no longer depends on order at all. The test that asserted "evaluates bypass
before caching" is replaced by one asserting mutual exclusion — verified to fail
if the old shape is reintroduced.

## `sec-fetch-dest: empty` would have bypassed the traffic this feature caches

Every `/_serverFn` call is an XHR and sends that header, so once the ordering
was fixed that clause would have bypassed exactly what
`cdnCacheControl: "serverfn-segment"` exists to cache — quietly reducing the
feature to a no-op. Carved out for server-fn paths, mirroring `buildCacheKey`,
which excludes them from `__fetch` for the same reason.

## `cdnCacheableServerFn` now fails closed on geo

`__cf_geo` is in the Worker key, cannot be expressed in the marker, and cannot
be reproduced by the CDN. Mostly moot — with geo on, the `buildSegment` wrapper
back-fills `regionId` and any `regionId` already nulls the token — but the
back-fill reads only `cf.regionCode` while `buildGeoCacheParam` keys on
country/region/city, so a request with a country and no region code slips
through. Refused outright, matching what the `match-profile` branch fifteen
lines below already did.

## The exit-point comment now describes what the code does

It claimed "enforced here, at the single exit, so a future bypass branch can't
forget it" while only acting on `X-Cache: BYPASS`. Branches returning before
`dressResponse` (`?asJson`, `?renderJson`, proxy, redirects) emitted no
`CDN-Cache-Control` at all. `no-store` is now the default when the header is
absent, so an early return can only ever be more restrictive than the cache
layer; a value `dressResponse` already decided is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JonasJesus42

Copy link
Copy Markdown
Contributor Author

Correções do security review

Quatro apontamentos endereçados no 8b53a3f. O primeiro era um bug real meu.

1. As Cache Rules dependiam de first-match-wins — e a Cloudflare não é

A fase de cache é last-match-wins para ações não-terminantes como
set_cache_settings. A regra catch-all (cache: true) era superconjunto estrito da regra de bypass
(cache: false), então a sobrescrevia em toda request — deixando a lista de bypass inteira inerte,
incluindo a cláusula do cookie de auth que o próprio arquivo chama de "a mais importante". Um visitante logado
teria recebido a entrada anônima compartilhada, com o Worker nunca rodando: exatamente o caso que as regras
existem para impedir.

Pior: o teste "evaluates bypass before caching" codificava a mesma premissa errada e certificava o bug.

Agora as duas regras são mutuamente exclusivas por expressão (and not (<clauses>)), então a corretude não
depende mais de ordem nenhuma. O teste foi substituído por um de exclusão mútua — e verifiquei que ele
falha se a forma antiga for reintroduzida.

2. sec-fetch-dest: empty bypassaria justamente o tráfego que essa feature cacheia

Toda chamada /_serverFn é XHR e manda esse header. Uma vez corrigida a ordem, essa cláusula passaria a
bypassar exatamente o que o serverfn-segment existe para cachear — anulando a feature em silêncio. Adicionado
carve-out para paths de server-fn, espelhando o buildCacheKey, que os exclui do __fetch pelo mesmo motivo.

3. cdnCacheableServerFn agora falha fechado em geo

__cf_geo está na chave do Worker, não cabe no marcador e o CDN não reproduz. Na prática já era quase coberto
— com geo ligado o wrapper de buildSegment back-filla regionId, e qualquer regionId já anula o token —
mas o back-fill lê só cf.regionCode enquanto buildGeoCacheParam chaveia por country/region/city. Uma
request com país e sem region code escapava. Recusado de vez, igual ao que o branch match-profile quinze
linhas abaixo já fazia.

4. O comentário do exit point agora descreve o que o código faz

Ele afirmava "enforced here, at the single exit, so a future bypass branch can't forget it" enquanto só agia
em X-Cache: BYPASS. Branches que retornam antes do dressResponse (?asJson, ?renderJson, proxy,
redirects) não emitiam CDN-Cache-Control nenhum. Agora no-store é o default quando o header está ausente
— um early return só pode ser mais restritivo que a camada de cache, nunca acidentalmente público — e um valor
que o dressResponse já decidiu é preservado.


Nota sobre o veredito do review: os três findings foram classificados abaixo do limite de confiança porque
nenhum é explorável hoje — o cdn-rules.ts só imprime JSON, ninguém aplicou ruleset nenhum, e a ativação exige
dois passos manuais. Isso está correto como avaliação de vulnerabilidade. Mas "não é explorável hoje" não é
"não precisa consertar": o ruleset gerado é o controle de segurança quando for aplicado, e o teste errado
era o que enganaria o próximo leitor.

2588 testes, +4 novos. Typecheck limpo. As 4 falhas em draft preview seguem pré-existentes na branch.

@JonasJesus42

Copy link
Copy Markdown
Contributor Author

Fechando em favor de PRs separados, conforme o review — eram duas coisas num PR só.

O gerador de Cache Rules saiu dos dois. O review apontou certo que em sites servidos por Workers não existe cache na frente, e que o caminho é o Workers Cache (cache: { enabled: true } no wrangler) em vez de Cache Rules na zona. Isso muda o desenho por completo — por worker em vez da zona compartilhada, config-as-code, versão do worker já na chave e Vary respeitado. Refaço a parte de HTML em cima disso, em PR próprio.

Os quatro fixes do security review que estavam no 8b53a3f seguem incluídos: dois deles (ordem das Cache Rules e carve-out do sec-fetch-dest) eram do gerador e saem junto com ele; o guard de geo no cdnCacheableServerFn está no #513 e o default de no-store no exit único está no #512.

@JonasJesus42
JonasJesus42 deleted the no-store-html-serverfn-tanstack branch September 1, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant