# F5.I3.T1 — `Cache-Control` + `ETag`/304 + CSRF fora do cache + CSP

| Campo | Valor |
|---|---|
| **Fase** | F5 — Borda |
| **Issue** | F5.I3 — Cache, resposta e hardening (plano 18) |
| **kd task** | `task_003lzgyn` |
| **Âncora** | `wiki/implementation/05-borda.md` |
| **Status da revisão** | 🔧 com correções |

## O que foi implementado

- `PublicResponseCache` (grupo da área Portal): renderiza a `View` e calcula o `ETag` = SHA-256 do
  corpo, marcando `cache_public`/`cache_private` no request.
- `StripPublicCookies` (global, **depois** de `StartSession`): remove o `Set-Cookie` anônimo do
  conteúdo público e fixa `Cache-Control: public, max-age=300, s-maxage=300,
  stale-while-revalidate=86400` + `Vary: Accept-Encoding, Cookie` + `isNotModified()` (304).
- `PrivateResponse` (alias `cache.private`) + detecção de `<form method="post">`: páginas de
  formulário/CSRF → `private, no-cache, no-store`.
- CSP do edge: Trusted Types **no CMS** (`require-trusted-types-for 'script'` +
  `trusted-types 'allow-duplicates' ProseMirrorClipboard`); portal sem TT (VLibras).
- **ETag só no `GET`**: o `Response::prepare()` do Symfony remove o corpo no `HEAD` antes dos
  middlewares — sem hash a calcular (o cache real usa `GET`).

## Arquivos para revisar

- `src/core/Internal/Http/Middleware/PublicResponseCache.php`
- `src/core/Internal/Http/Middleware/StripPublicCookies.php`
- `src/core/Internal/Http/Middleware/PrivateResponse.php`
- `src/core/Internal/Routing/AreaRegistrar.php`
- `bootstrap/app.php`
- `src/modules/Portal/Lgpd/Routes/portal.php`
- `docker/web/Caddyfile`
- `tests/Feature/ResponseCacheTest.php`
- `tests/Feature/SecurityHeadersTest.php`
- `src/tests-e2e/tests/14-security-headers.spec.ts`
- `src/tests-e2e/histories/14-security-headers.md`
- `wiki/specs/05-seguranca-lgpd.md`
- `wiki/plan/18-otimizacao-resposta-hardening.md`

## Pontos de revisão

### PHP 8.4
- [ ] `declare(strict_types=1)`; tipos nativos (union/enum/`readonly`); sem `mixed` desnecessário.
- [ ] Dependências **injetadas** (nenhum `new`/`app()`/`resolve()` no corpo); sem traits/abstract/generics.
- [ ] `match` em vez de cadeias de `if`; `?->`/`??`; sem código morto; PHPStan `max` limpo.

### Laravel 12
- [ ] Nativo primeiro (Eloquent casts/scopes, `FormRequest`, `Gate`, scheduler, events) — sem reimplementar.
- [ ] Sem closure em rota; resources/rotas nomeadas; `config()` só lê, `env()` só em `config/`.
- [ ] Middleware global x grupo: o `Set-Cookie` só é removível **depois** de `StartSession`.

### HTML semântico + a11y (eMAG/WCAG 2.2 AA)
- [ ] Página com formulário não é cacheável; nenhum `csrf-token` em HTML público.
- [ ] `Vary: Cookie` cobre as preferências de a11y renderizadas no SSR (ADR-016).

### CSS moderno
- [ ] Sem `!important`, sem `style="…"` inline; sem CDN.

### Decisões do projeto (ADRs)
- [ ] ADR-022 (cache/ETag/CSRF), ADR-020 (CSP/Trusted Types), ADR-016 (a11y no SSR).
- [ ] Determinismo: duas requisições idênticas → mesmo `ETag` (ADR-001).

### i18n (ADR-012)
- [ ] Sem string visível fixa; 4 locales com paridade.

### Observabilidade/segurança/testes
- [ ] Sem PII em logs; `X-Request-ID` presente.
- [ ] Teste que falha sem a mudança (vermelho→verde) + evidência no `kd`; `make check E2E=1` 2×.

### Específicos desta task

- [ ] `Set-Cookie` ausente no público; `private, no-store` nas páginas de formulário.
- [ ] `304` dispara com `If-None-Match` do `ETag` do `GET`.
- [ ] CSP do CMS com Trusted Types; portal sem `unsafe-inline`/`'nonce-'`.
- [ ] Assets do Vite `immutable` (T2).

## Comandos úteis

```bash
make check              # único caminho de validação (rápido)
make check E2E=1        # + Playwright/axe/perf (2× para anti-flaky)
make exec cmd='vendor/bin/phpunit tests/Feature/ResponseCacheTest.php'
tools/e2e.sh test tests/14-security-headers.spec.ts
```

---

> Revisar contra `AGENTS.md`, `wiki/adr/` e o plano citado. Registrar achados no `kd` (`kd write --anchor`) e, se houver correção, teste de regressão.

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-19 | agente (PROTOCOLO v2.0) | 🔧 | **A** (**alto**, D11/ADR-022) `StripPublicCookies` só agia com `cache_public`, e o `PublicResponseCache` só marcava **HTML** 200: `sitemap.xml`, `robots.txt`, `manifest.webmanifest`, `.well-known/security.txt` e a mídia saíam com `Cache-Control: public, s-maxage` **e** `Set-Cookie` (`concordia-session`/`XSRF-TOKEN`) — cache compartilhado envenenado (medido por curl) → marcador `cache_public_asset` + TTL do controller preservado + `Vary: Accept-Encoding`; teste vermelho→verde (`fact_00hs9uqw`). **B** (**alto**, D13) `make check` podia reportar “app online” em porta diferente da do compose (`WEB_PORT`) → porta **derivada** do `.env` nos três scripts + `CheckPipelineTest` (3 testes, vermelho antes) (`fact_01jvjp28`). **C** (**alto**, D13) o caminho dourado não aplicava migrações: `/galeria` respondia **500** (`relation "gallery.gallery_media" does not exist`) e o e2e só via página de erro → passo `prep:migrate` + gate (`fact_01jvjp28`). **D** (**alto**, D13/evidência) o e2e 14 pegou o `web` rodando **Caddyfile antigo** (CSP de `cms.localhost` sem Trusted Types) — gate funcionando, drift de ambiente registrado (`risk_01qke8zj`). **E** (**médio**, D14) comentário do `tools/check.sh` dizia `:8090` enquanto o código usava `:8080` → corrigido junto da derivação. **F** (baixo, D2) `Vary` era sobrescrito sem `has()`; resposta não-HTML agora mantém o `Cache-Control` do dono. |

Evidência: `make check E2E=1` verde (22 OK · 0 FAIL · 1 SKIP); e2e 14/15 verdes em `:8090`; `ResponseCacheTest` 8/8. |

> Resultado: `✅ revisado` · `🔧 com correções` · `⏭️ delegado` (com justificativa).
