# 09 — Borda Multi-CDN: cache, failover e purge (A2/A3/A4)

> **Escopo:** camada de borda (CDN/WAF/geo-balancer) que consome o HTML e os
> estáticos otimizados pela origem. Complementa [`04-cache-performance.md`](04-cache-performance.md)
> e [`06-deploy-operacao.md`](06-deploy-operacao.md).
>
> **Estado no snapshot:** a parte **app-side** está implementada
> (`Cache-Control` público, ETag fraco, chave normalizada, `CdnPurgeService`
> no-op seguro, `sw.js` revalidável). A contratação/configuração dos provedores
> (**F2.2–F2.5**) é **externa** e não está no repositório. Nada aqui deve ser
> lido como “CDN já configurada”.

---

## 1. O que está implementado vs. o que é externo

| Item | Onde | Estado |
|---|---|---|
| `Cache-Control: public, s-maxage=300, stale-*` no HTML | `CacheControl` | ✅ implementado |
| Remoção de `Set-Cookie`/`Pragma`/`Expires` no HTML público | `CacheControl` | ✅ implementado |
| ETag fraco + `304` | `CacheControl` | ✅ implementado (CSP desligado) |
| `Vary: Accept-Encoding` sem `User-Agent` | `CacheControl` + `.htaccess` | ✅ implementado |
| Chave de FPC normalizada (`utm_*`, `gclid`, …) | `PageCache` + `config/cache.php` | ✅ implementado |
| Purge Multi-CDN no-op seguro | `CdnPurgeService` + `config/cdn.php` | ✅ implementado (sem provedor) |
| `sw.js` com `max-age=0, must-revalidate` | `public/.htaccess` `[L0.3b]` | ✅ implementado |
| Provedores CDN, WAF, TLS, geo-balancer | Contrato/infra | ⛔ **externo (F2.2)** |
| Failover geográfico / health checks | DNS/anycast | ⛔ **externo (F2.3)** |
| Purge síncrono multi-provedor | `CdnPurgeService` + credenciais | ⛔ **externo (F2.4)** |
| RUM/Catchpoint + medição final A1–A4 | Monitoramento | ⛔ **externo (F2.5)** |

> **Regra:** o app não pode depender da borda. Publicar conteúdo jamais pode
> falhar porque o CDN está fora. O `CdnPurgeService` já absorve falhas; a borda é
> otimização, não dependência de consistência.

---

## 2. Modelo de cache em três níveis

```
Navegador ──► Borda/CDN ──► Origem (Apache + PHP-FPM + Laravel)
   │             │              │
   │             │              ├─ PageCache (FPC, file/redis, 300s)
   │             │              ├─ CacheControl (headers/ETag)
   │             │              └─ Layout cache (menus/config/banners, 300s)
   │             │
   │             ├─ HTML: s-maxage=300, stale-while-revalidate=86400,
   │             │        stale-if-error=604800, sem Set-Cookie
   │             └─ estáticos: max-age=31536000, immutable (versionados)
   │
   └─ HTML: max-age=300; estáticos: 1 ano
```

| Nível | O que guarda | TTL | Invalidação |
|---|---|---|---|
| Navegador | HTML, CSS/JS/fontes/imagens | 300s / 1 ano | URL nova (hash) ou `must-revalidate` |
| Borda | HTML, estáticos, WebP | `s-maxage` / `max-age` | TTL, purge, `stale-*` |
| Origem (FPC) | HTML renderizado | `PAGE_CACHE_TTL` (300s) | eventos de modelo + `cache:page:clear` |
| Origem (layout) | menus/config/banners | 300s | `PageCache::flushLayout()` |

### 2.1 Por que `s-maxage` e não só `max-age`

`max-age` vale para qualquer cache (navegador e compartilhado). `s-maxage`
sobrescreve `max-age` **apenas em caches compartilhados** (CDN/proxy). Com
`s-maxage=300`, a borda pode manter o HTML por 5 min mesmo que o navegador
revalide antes. `stale-while-revalidate` e `stale-if-error` permitem servir
conteúdo levemente velho enquanto a origem revalida ou está indisponível.

### 2.2 Por que remover `Set-Cookie`

Nenhum CDN sério cacheia uma resposta com `Set-Cookie` por padrão — o cookie pode
ser específico da sessão. O `CacheControl` remove o header **e** esvazia o array
de cookies da resposta. O token CSRF é obtido client-side em `/csrf-token`
(rota excluída do cache), então o HTML pode ser compartilhado entre sessões.

---

## 3. Contrato de headers origem → borda

| Header | Valor esperado no HTML público | Efeito na borda |
|---|---|---|
| `Cache-Control` | `public, max-age=300, s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800` | Cacheia HTML por 300s; serve stale em revalidação/erro. |
| `ETag` | `W/"<md5>"` | Revalidação condicional; `304` sem corpo. |
| `Vary` | `Accept-Encoding` | Uma variante gzip/brotli; **não** fragmenta por UA. |
| `Set-Cookie` | **ausente** | Permite cache compartilhado. |
| `X-Page-Cache` | `HIT`/`MISS` | Diagnóstico de origem; **não** deve entrar na chave. |
| `Server-Timing` | `app;dur=...` | Diagnóstico; não cachear como parte do corpo. |
| `Content-Encoding` | `gzip`/`br` | A borda pode recomprimir; preserve `Vary`. |
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` | HTTPS obrigatório. |
| `Cross-Origin-Resource-Policy` | `same-origin` | Isolamento de origem. |

> **Atenção:** se a borda adicionar `Vary: User-Agent`, o hit ratio despenca. A
> causa raiz no snapshot era um `.htaccess` de conta com
> `Header append Vary User-Agent`; o `[L0.3c]` do app é apenas defesa. Ver
> `04-cache-performance.md` §12.2.

---

## 4. Chave de cache e normalização

A chave do FPC é:

```php
'page_cache:v1:' . md5(PageCache::normalizedUrl($request) . '|' . app()->getLocale())
```

`normalizedUrl()`:

1. remove parâmetros de tracking listados em
   `config('cache.page_cache.ignored_query_params')`;
2. ordena os parâmetros restantes (`ksort`);
3. reconstrói a query com `http_build_query`.

Exemplo:

```text
/?utm_source=facebook&gclid=abc   -> chave de /
/?page=2                          -> chave distinta
/?b=2&a=1                         -> mesma chave de /?a=1&b=2
```

**A borda deve espelhar a mesma regra.** Se o CDN usar a URL bruta como chave,
`?utm_source=x` cria uma entrada nova e o hit ratio cai. Configure “ignore query
args”/“cache key normalization” no provedor com a mesma lista:

```text
utm_source utm_medium utm_campaign utm_term utm_content utm_id
utm_source_platform utm_creative_format utm_marketing_tactic
gclid gbraid wbraid dclid fbclid msclkid yclid twclid ttclid
igshid mc_cid mc_eid _hsenc _hsmi _ga _gl
```

> **Não** ignore `page`, filtros, categoria, busca ou locale. Eles mudam o
> conteúdo.

---

## 5. Configuração de purge (`CdnPurgeService`)

### 5.1 Contrato

```php
$service = app(\App\Services\Cdn\CdnPurgeService::class);
$service->purgeAll();                 // purge_everything em todos os providers
$service->purge(['https://host/a']);  // purge por URL
```

- `config('cdn.enabled') = false` por default → **no-op sem rede**.
- `config('cdn.providers') = []` → no-op.
- Cada provider tem `enabled`, `driver` (`cloudflare`/`fastly`/`webhook`) e
  credenciais.
- Qualquer falha é logada (`cdn.purge.failed`) e **não** sobe para o caller.
- `PageCache::flush()` chama `purgeAll()` dentro de `try/catch`; publicar conteúdo
  nunca falha por causa do CDN.

### 5.2 Exemplo de `config/cdn.php` com dois provedores

```php
return [
    'enabled' => env('CDN_PURGE_ENABLED', false),
    'timeout' => (int) env('CDN_PURGE_TIMEOUT', 2),
    'connect_timeout' => (int) env('CDN_PURGE_CONNECT_TIMEOUT', 1),

    'providers' => [
        [
            'name'    => 'cloudflare-primary',
            'driver'  => 'cloudflare',
            'enabled' => true,
            'zone'    => env('CDN_CLOUDFLARE_ZONE'),
            'token'   => env('CDN_CLOUDFLARE_TOKEN'),
        ],
        [
            'name'    => 'fastly-secondary',
            'driver'  => 'fastly',
            'enabled' => true,
            'service' => env('CDN_FASTLY_SERVICE'),
            'token'   => env('CDN_FASTLY_TOKEN'),
        ],
    ],
];
```

### 5.3 O que cada driver faz

| Driver | `purgeAll()` | `purge($urls)` |
|---|---|---|
| `cloudflare` | `POST /zones/{zone}/purge_cache` com `{"purge_everything":true}` | `{"files":[...]}` |
| `fastly` | `POST /service/{service}/purge_all` | `PURGE <url>` por URL |
| `webhook` | `POST <url>` com `{"purge_everything":true,"files":[]}` | `{"purge_everything":false,"files":[...]}` |

### 5.4 Purge síncrono vs. assíncrono

- **Síncrono no publish:** o `PageCache::flush()` já chama o serviço; se o CDN
  demorar, a publicação espera até `timeout` (default 2s) por provider. Com dois
  providers, o pior caso é ~4s. Aceitável para publicação; **não** use no caminho
  de leitura.
- **Assíncrono (recomendado em escala):** enfileirar um job (`QUEUE_DRIVER`) e
  responder ao CMS imediatamente. O contrato do serviço continua o mesmo.
- **Idempotência:** `purgeAll()` pode ser repetido; `purge($urls)` deduplica URLs
  (`array_unique`).
- **Retry:** o serviço atual **não** faz retry. Se necessário, envolver em job com
  backoff e `tries`.

### 5.5 Validação do purge

```bash
# Com provider desligado (default): não faz rede e retorna 0
php artisan tinker --execute="var_dump(app(\App\Services\Cdn\CdnPurgeService::class)->purgeAll());"
# esperado: int(0)

# Com provider habilitado: observar log
tail -f storage/logs/laravel.log | grep cdn.purge
```

> **Nunca** coloque token de CDN em `.env.example` com valor real. Use
> `CDN_*` vazio/comentado e configure no cPanel › Environment Variables ou no
> gerenciador de segredos.

---

## 6. Failover e origem

### 6.1 Requisitos A3

- **Dois ou três provedores** com DNS/anycast steering (latência/geografia) ou
  um provedor com múltiplas POPs + origin shield.
- **Health checks** na origem (HTTP 200 em `/` ou endpoint leve) com remoção
  automática do pool.
- **Origin shield** para reduzir carga e unificar cache.
- **TLS** equivalente em todos (TLS 1.2+/1.3, HSTS, OCSP stapling).
- **WAF** com regras equivalentes; não criar bypass por provedor.
- **Failover testado** (derrubar primário e medir recuperação).

### 6.2 O que **não** fazer

- Não apontar o DNS diretamente para o IP da origem sem shield; um ataque de
  tráfego derruba o cPanel.
- Não usar TTL de DNS alto sem plano de failover; mantenha TTL baixo (60–300s)
  durante a migração.
- Não cachear HTML autenticado (`/cms`, `/prefecture`, `/admin`): o
  `CacheControl` já exclui por namespace; a borda deve ter regra explícita de
  bypass para esses paths.
- Não cachear `POST`, `Set-Cookie`, `Authorization`, `no-store`.

### 6.3 Regras de bypass na borda

```text
Bypass cache (sempre origem):
  /cms/*
  /admin/*
  /prefecture/*
  /csrf-token
  /csp-report
  Qualquer método != GET/HEAD
  Qualquer resposta com Set-Cookie
  Qualquer resposta Cache-Control: no-store/private
```

`/robots.txt`, `/sitemap.xml` e `/manifest.webmanifest` podem ser cacheados, mas
com TTL curto (ex.: 300s) e revalidação; eles mudam por host.

---

## 7. Coexistência com CSP (A4 × SEC-1e)

O plano `2026-09-10-csp-enforce-edge-coexistence.md` documenta a trava:

- **Nonce por request + HTML cacheado na borda em modo enforce** = *static nonce
  vulnerability*: o nonce do primeiro MISS é servido a todos e pode ser reutilizado.
- **Solução adotada (#2):** a origem pode emitir `Content-Security-Policy`
  (enforce) com nonce fresco; a **borda serve apenas `Report-Only`** (downgrade no
  flip). Em Report-Only não há bloqueio, então o nonce fixo não é explorável.
- **Fallback (#2 do plano):** se a borda não puder fazer o downgrade, não cachear
  HTML na borda; manter apenas estáticos. Isso reverte parte do A2, mas preserva
  o enforce.

> **Estado atual:** `config('csp.enabled') = false`; não há header CSP. Antes de
> ligar/enforce, leia [`05-seguranca.md`](05-seguranca.md) §3.2.1 e
> [`10-achados-e-patches.md`](10-achados-e-patches.md) F-01: a ordem
> `CspNonce` × `CacheControl` no snapshot quebra o ETag quando o CSP é ligado.

---

## 8. Critérios de aceite (A1–A4)

| Critério | Como medir | Alvo |
|---|---|---|
| **A1** TTFB/LCP | RUM + Catchpoint/Lighthouse em regiões | TTFB < 800 ms; LCP ≤ 2500 ms |
| **A2** cache hit | headers `X-Cache`/`CF-Cache-Status`/`Age` | HTML: hit > 90% após warm-up; estáticos > 95% |
| **A3** disponibilidade/failover | health checks + derrubada do primário | 100% disponível; failover < 60s |
| **A4** purge | publicar conteúdo e medir | purge propagado < 60s; sem conteúdo velho |
| Compressão | `curl --compressed` | `Content-Encoding: gzip`/`br` |
| Revalidação | `If-None-Match` | `304` |
| Segurança | headers + CSP | HSTS/CORP/COOP; CSP conforme plano |
| Regressão | Lighthouse budget | LCP/CLS/TBT dentro do budget |

### 8.1 Comandos de validação

```bash
# Cache de borda (exemplos de headers; variam por provedor)
curl -s -D - -o /dev/null https://SEU_HOST/ \
  | grep -iE 'cache-control|age|x-cache|cf-cache-status|vary|set-cookie|etag'

# Aquecimento e segunda requisição
curl -s -o /dev/null https://SEU_HOST/
curl -s -D - -o /dev/null https://SEU_HOST/ | grep -iE 'age|x-cache|cf-cache-status'

# Estático versionado
curl -sI https://SEU_HOST/assets/css/app.css \
  | grep -iE 'cache-control|age|x-cache|content-encoding'

# Purge (após publicar)
php artisan cache:page:clear
# aguardar propagação e verificar que o HTML novo aparece

# Failover (exemplo conceitual; depende do provedor)
# 1) desabilitar o primário no painel/DNS
# 2) medir disponibilidade e TTFB
# 3) restaurar e validar consistência
```

### 8.2 Medição sem CDN (baseline de origem)

Antes de contratar a borda, meça a origem para separar o ganho do app do ganho da
borda:

```bash
curl -s -D - -o /dev/null https://SEU_HOST/ \
  | grep -iE 'server-timing|x-page-cache|cache-control|etag'
# Server-Timing: app;dur=...  -> TTFB de origem
# X-Page-Cache: HIT/MISS     -> FPC
```

---

## 9. Rollout recomendado

1. **Origem primeiro:** validar FPC, `Cache-Control`, ETag/304, `Vary`, `sw.js`,
   compressão e formulários sem CDN.
2. **Borda em modo “cache estáticos”:** CSS/JS/fontes/imagens; HTML ainda direto.
3. **HTML cache com TTL curto (60s)** em um domínio canário; medir hit ratio,
   `Age`, purge.
4. **Subir para 300s** e ativar `stale-while-revalidate`/`stale-if-error`.
5. **Failover:** adicionar secundário, health checks e teste de derrubada.
6. **Purge multi-provedor:** habilitar credenciais, testar `purgeAll()` e
   `purge($urls)`, monitorar logs.
7. **RUM/Catchpoint + budget Lighthouse:** fechar A1–A4.

### 9.1 Rollback

| Item | Reverter |
|---|---|
| Cache HTML na borda | regra para bypass de HTML ou TTL 0; manter estáticos |
| Purge | `CDN_PURGE_ENABLED=false`; serviço vira no-op |
| Failover | remover secundário do DNS/pool; restaurar primário |
| CSP | manter `config('csp.enabled') = false` ou Report-Only |
| Compressão | cPanel Optimize Website → Disabled |

---

## 10. Riscos residuais

1. **Purge não é atômico** entre provedores; pode haver janela de inconsistência.
2. **Chave de cache divergente** entre app e borda fragmenta o hit ratio.
3. **`Vary: User-Agent`** pode ser reintroduzido por `.htaccess` de conta ou
   configuração do provedor.
4. **HTML com nonce** não pode ser cacheado em enforce sem downgrade/worker.
5. **Uploads `immutable`** (ver F-02) podem servir imagem antiga por 1 ano; a
   borda herda o problema.
6. **CSP Report-Only na borda** exige configuração explícita; se a borda
   “promover” o header, o nonce fixo vira vulnerabilidade.
7. **Custo/complexidade** de multi-CDN sem observabilidade adequada.
