# F5 — Borda (SEO/perf · dados abertos/LGPD · cache/hardening · media)

> **Épico:** `F5` · **Depende de:** F4 · **Habilita:** F6 · **Planos:** 14, 17, 18, 19

## Objetivo

Fechar a superfície externa e de conformidade: SEO/performance, dados abertos e LGPD, cache/ETag e
hardening, e o fluxo único de mídia/galeria.

## Issues e tasks

### `F5.I1` — SEO e performance (plano 14)
- **T1** `services/Seo` headless + `<x-service-seo::tags>` + JSON-LD `@graph` (Government…/Dataset).
  **Feito:** service reescrito do legado (`services/SEO` → `services/Seo`, ADR-015): contratos
  `SeoBinder`/`SeoMetadataGenerator`/`SeoGraphBuilder`, `SeoData` (readonly), resolução com
  sufixo de título + fallbacks globais, `<x-service-seo::tags>` como **fonte da verdade do `<head>`**
  (o shell do portal só o inclui) e `@graph` determinístico (`GovernmentOrganization` +
  `WebSite`/`SearchAction` + `GovernmentPage` eMAG + `BreadcrumbList` + nós do módulo, ex. `Dataset`).
  Testes `SeoServiceTest`/`SeoGraphTest`/`SeoTagsTest`; rotas de raiz mantidas (refino em T2).
- **T2** Arquivos de raiz dinâmicos (`robots.txt`/`sitemap.xml`/`security.txt`/manifest); 404 real +
  301; `hreflang`/canonical por locale.
  **Feito:** `sitemap.xml` (índice) + `sitemap-{fonte}.xml` (`urlset`, `lastmod`, `hreflang`) a partir
  do `Core\Contracts\SitemapRegistry` (Home → `/`; Notícias → publicadas) — porta `Ports\SitemapSource`
  no Core (módulo registra no `register()`); `robots.txt` por área, `security.txt` e
  `manifest.webmanifest`; **ETag/304 + `Cache-Control`** em todos; **301** de URLs legadas
  (`seo.redirects` + middleware global restrito ao portal, rota viva nunca sombreada, same-origin) e
  **404 real** com `X-Robots-Tag: noindex` + log `http.not_found`. `hreflang`/canonical por locale já
  vinham do T1. Testes `SeoSitemapTest`/`SeoRedirectTest`/`ContractsBindingTest`.
- **T3** Core Web Vitals (LCP/INP/CLS) com orçamento; `precompressed` no Caddy.
  **Feito:** plugin `concordia:precompress` no `vite.config.js` (`node:zlib`, sem dependência nova)
  gera `.br`/`.gz` **determinísticos** (brotli q11 / gzip 9, mtime 0) para todo asset de texto ≥ 1 KB;
  o `file_server` do Caddy serve via `precompressed br gzip` (o build não gera `.zst`; zstd fica
  no `encode`, só para resposta dinâmica). Orçamento **no CI** com
  `PerformanceBudgetTest` (existência das variantes + ausência de timestamp no gzip + tamanho
  brotli por entrada + anti-N+1: contagem de queries constante de 1→10 itens + orçamento de queries
  da home) e E2E `11-performance.spec.ts` (**CLS ≤ 0,1** e zero recurso bloqueando a renderização,
  desktop+mobile). Alvo: etapa `perf` do `make check E2E=1`. LCP/INP (medida de parede) ficam para
  o RUM — não entram no CI (ADR-001).

### `F5.I2` — Dados abertos e LGPD (plano 17)
- **T1** Dataset como entidade + metadados DCAT/INDA + `schema.org/Dataset`; export CSV/JSON/XML com
  `ETag`/checksum; catálogo `/dados-abertos` + API pública (CC-BY).
  **Feito:** módulo `Portal/DadosAbertos` (`dados_abertos.datasets`, dados em `jsonb`); metadados
  DCAT/INDA + `schema.org/Dataset` em `Seo/DadosAbertosSchema` (entram no `@graph` do service `Seo`);
  export **CSV/JSON/XML** determinístico (`DadosAbertosExport`) com `checksum` SHA-256, `ETag`/304 e
  URL estável; catálogo `/dados-abertos` + detalhe; **API** ``api.<base>/v1/datasets`` (paginada, `ETag`,
  `throttle:open-data`, sem PII); CRUD no CMS (`dados_abertos.{ver|editar|aprovar|arquivar}`);
  sitemap + seeder demo. Testes `DadosAbertosTest` + E2E `12-dados-abertos`.
- **T2** LGPD como programa: classificação, **DSAR**, retenção/expurgo, `legal hold`, auditoria,
  runbook ANPD, canal DPO, analytics privacy-first (sem GA).
  **Feito:** módulo `Portal/Lgpd` — **DSAR** (`lgpd.dsar_requests`, dado pessoal **cifrado** com cast
  `encrypted`, protocolo, prazo `LGPD_DSAR_DUE_DAYS`, **auditoria explícita**); política
  `/politica-de-privacidade` + canal do **DPO** (rodapé e `security.txt`); atendimento no CMS
  (`lgpd.{ver|editar}`). **Retenção:** `Core\Contracts\RetentionRegistry` + `Ports\RetentionTarget` +
  comando agendado `retention:expunge` → o alvo `lgpd-dsar` **anonimiza** protocolos encerrados além
  do prazo, preserva o histórico e **respeita `legal_hold`** (nada de `DROP`). Runbook
  `wiki/runbooks/anpd-incidentes.md`. `AuditEntry::event/access` agora são **explícitos** (a política
  por área barra só a trilha ambiente do portal). Testes `LgpdTest` + `AuditTrailTest`. Pendente: ROPA
  como tabela (hoje config).
- **T3** Spec `05-seguranca-lgpd.md`.
  **Feito:** `wiki/specs/05-seguranca-lgpd.md` (Dono **Core/Segurança + Portal/Lgpd**; `Em execução`;
  plano 17/18) no índice: fronteira com as specs 16/14/11/12/04/06; headers/CSP (dono = edge/spec 16),
  cookies/sessão, login CMS (ADR-037), rate limit 3 camadas (ADR-021), cache/`ETag`/CSRF (ADR-022) e
  upload; LGPD (classificação, DSAR, retenção/legal hold, auditoria de acesso, DPO, incidentes ANPD,
  analytics privacy-first) + STRIDE; pendências explícitas (F5.I3/I4, ROPA/DPIA).

### `F5.I3` — Cache, resposta e hardening (plano 18)
- **T1** `Cache-Control` + **ETag/304** no conteúdo público (corpo determinístico); form pages
  `private`; CSRF fora do HTML cacheável; CSP **hash-based** cacheável + Trusted Types — ADR-020/022.
  **Feito:** `PublicResponseCache` (grupo da área: renderiza a `View` e calcula o `ETag` SHA-256 do
  corpo) + `StripPublicCookies` (global: remove o `Set-Cookie` anônimo **depois** de `StartSession`
  e fixa `Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=86400`, `Vary:
  Accept-Encoding, Cookie`); `PrivateResponse` (alias `cache.private`) + detecção de `<form
  method="post">` → `private, no-store` no `/lgpd` e `/lgpd/recebido` (nunca token CSRF em HTML
  cacheável). O `ETag` é emitido **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`). **CSP Trusted
  Types** no **CMS** (`require-trusted-types-for 'script'` + `trusted-types 'allow-duplicates'
  ProseMirrorClipboard`, o ProseMirror cria a policy; portal fica sem TT por causa do VLibras) e
  **assets do Vite `immutable`** (`@viteAssets`), no `docker/web/Caddyfile` (matchers `@cms`/
  `@portal`/`@embed`). Testes `ResponseCacheTest` + gate de fonte em `SecurityHeadersTest` + e2e `14`.
- **T2** `ETag` estável (sem `'nonce-'`/`unsafe-inline`); assets Vite `immutable`; FPC opcional.
  **Feito:** CSP sem `'nonce-'`/`unsafe-inline` (gate de fonte em `SecurityHeadersTest` + e2e `14`);
  assets `/build/assets/*` servidos com `Cache-Control: public, max-age=31536000, immutable`
  (`@viteAssets`, cobre também `.br`/`.gz`). **FPC (Valkey) descartado** — ADR-022 §6 prevê
  descartar sem ganho demonstrado; `ETag`/304 + `stale-while-revalidate` cobrem.
- **T3** Rate limiting 3 camadas (server/app/forms) com `429` RFC 7807 — ADR-021.
  **Feito:** borda Apache (`RequestReadTimeout`, `LimitRequestBody`, `LimitRequestLine`,
  `LimitRequestFields` + `mod_qos`/`mod_evasive` condicionais) e Caddy (timeouts +
  `request_body max_size 110MiB`); app com `throttleWithRedis` + limitadores nomeados
  (`ThrottlePanelLogin` e-mail+IP, `open-data`, `lgpd-dsar`, `media.upload`), honeypot/time-trap no
  DSAR e **429 RFC 7807 + `Retry-After`** (renderer + `ProblemDetailsTest`/`CmsAuthTest`/
  `LgpdTest`); gate `tools/security-check.sh` valida borda, perfil `production`, `php.ini`, sessão,
  headers, debugbar, `.env` fora do docroot e `TrustProxies`/`TrustHosts` — roda no `make check` e no
  `gates-selftest` (com fixture inválida). O corpo aceita o **maior** hard de mídia (PDF 100 MiB):
  o limite por tipo (soft = aviso, hard = recusa) é do app (`config/media.php` + `MediaLimits`),
  não da borda — `MediaLimitsTest` prova a consistência com Apache/Caddy/PHP.
- **T4** Fronteira de storage: **o service `Media` é o único dono** — nenhum módulo/service/layout
  toca `Storage`/Flysystem/AWS; todos usam o caminho definido pelo Media (ADR-026/ADR-030).
  **Feito (teste antes):** `tests/Feature/StorageBoundaryTest.php` (escrito **antes** da
  implementação) capturou o latente `Demo/ProcessMilestoneMedia` — código morto que fazia
  `Storage::disk('local')` + `Str::random(5)` (não determinístico) — removido para `.pi/trash/`;
  regra de AST no `tools/arch-check` (`isStorageApi`) falha qualquer arquivo fora de
  `src/services/Media/**` (e do provisionamento de bucket em
  `src/core/Internal/System/Storage/**`) que importe `Illuminate\Support\Facades\Storage`,
  `League\Flysystem\*`, `Aws\*` ou chame `Storage::`; fixtures de gate em
  `tools/tests/fixtures/arch/storage-{invalid,valid,owner-valid}` cobertas por `gates-selftest`;
  o teste estrutural também fixa o padrão `services.media.disk=s3` e os defaults
  `env('FILESYSTEM_DISK', 's3')`/`env('MEDIA_DISK', 's3')`. `Storage::fake()` em `**/Test/**` fica
  fora (dublê de teste, não persistência), documentado no teste.

### `F5.I4` — Media e Gallery (plano 19)
- **T1** `MediaInputer` (entrada única, roteada por MIME) → AVIF/WebP `[360..2560]` com `width/height`.
  **Feito:** MIME **real** (finfo) dentro do serviço + allowlist fechada; SVG fica vetorial (sem
  variantes); imagem < 360 gera **1 variante na largura original**; imagem grande nunca gera
  largura > original; nome de objeto determinístico (`IdGen`, UUIDv7) e `checksum` SHA-256 no
  payload. `StoreEditorMedia` emite AVIF (inputer) + WebP (mesma largura) e `width`/`height`
  (CLS 0). Testes `MediaInputerTest` + `MediaUploadTest`.
- **T2** Catálogo `gallery_media` + `<x-service-media::picture>`; Flysystem+queue; `alt_text` obrigatório.
  **Feito:** upload do editor **assíncrono** — `media.media_uploads` (`pendente → processando →
  concluido | falha`), `ProcessMediaUpload` (fila Valkey) e `POST 202` + polling
  (`media.upload.status`); `SecureMediaStorage` grava por **stream** em quarentena (`staging/`,
  nunca servida) e materializa para a fila. Novo service **`Gallery`** (`gallery.gallery_media`,
  `GalleryStore`, `GalleryConfig`, `alt_text` obrigatório no request + `CHECK` no banco,
  `checksum`, `archive/restore` sem apagar o físico) + superfície `/cms/galeria` no shell CMS.
  `<x-media::picture>` (AVIF/WebP + `width`/`height`) já era a renderização única; o editor
  continua inserindo as chaves do objeto e o parser (ADR-028) monta o `<picture>`.
  Testes `MediaUploadTest`/`SecureMediaStorageTest`/`GalleryTest`.
- **T3** Sem middleware que reescreve `<img>`; upload seguro (MIME real, tamanho, checksum).
  **Feito:** gate estrutural `MediaNoRewriteTest` (nenhum middleware/`Http` com `<img`/`DOMDocument`,
  `bootstrap/app.php` sem rewriter); MIME real na **borda** (finfo + allowlist, 422 para disfarce),
  limite de tamanho, `checksum` SHA-256 e quarentena `staging/` **404** no stream público.
- **T4 — Lote (F5.I4.b):** 20–50 imagens de 2–3 MB **nunca** num único multipart. **Feito:**
  `media.media_batches` (dono, `expected_files`, 50 arquivos/250 MiB/24h) + `media_uploads.batch_id/
  user_id`; reserva atômica (advisory lock do usuário + row lock do lote) antes de `staging/`;
  cota **2 GiB/dia** e rate `media-upload` 120/min; cliente com concorrência 3 e alt por imagem na
  grade; commit do Gallery (`GalleryService::catalog`) idempotente, só `concluido`, com falhas
  parciais. Testes `MediaBatchTest` (inclui **50 arquivos**), `GalleryBatchTest` e e2e
  `15-cms-galeria-lote`; `MediaLimitsTest`/`MediaBatch` cobrem borda/cota.

## DoD (binário)

- [x] Sitemap/JSON-LD/404/301 corretos; Lighthouse/CWV no orçamento.
- [x] Dataset exportável com `ETag`/checksum; catálogo e API públicos.
- [x] `ETag`/304 estáveis; CSP sem inline/nonce; Trusted Types no CMS; headers do ADR-020 presentes.
- [x] Fluxo de mídia gera variantes e CLS 0; `alt_text` obrigatório (F5.I4).
- [x] Lote 20–50 imagens com orçamento 50/250 MiB, cota 2 GiB/dia, alt por imagem e commit
  idempotente (F5.I4.b).
- [x] `make check E2E=1` 2× verde (caminho único de validação).

## Revisão (2026-10-19 — PROTOCOLO v2.0, fichas em `_to_revision/`)

Escopo revisado: **F5.I2.T3** e **F5.I3.T1 → F5.I4.T4** (o que já estava implementado). Cada ficha
tem achados, severidade, disposição e evidência; os achados estão no `kd` (`kd ask "F5 borda"`).

**Corrigido nesta sessão** (com teste vermelho→verde):

- `fact_00hs9uqw` (**alto**, ADR-022) resposta pública **não-HTML** cacheável (`sitemap.xml`,
  `robots.txt`, `manifest.webmanifest`, `security.txt`, mídia) saía com `Set-Cookie` de sessão
  anônima — `StripPublicCookies` só cobria HTML (`ResponseCacheTest`).
- `error_01t2yiow` (**alto**, ADR-033) `GET /cms/media/upload/{upload}` lia o upload de **qualquer**
  sessão do painel (IDOR) — 404 por dono (`MediaUploadTest`).
- `fact_014y467m` (médio, ADR-018/022) API pública respondia `ETag` com `Cache-Control: no-store,
  private` — nenhum cache podia guardar (`DadosAbertosTest`).
- `fact_01jvjp28` (**alto**, D13) caminho dourado não reconstruía assets velhos nem aplicava
  migrações (`/galeria` 500 no e2e) — `assets_stale()` + `prep:migrate` + gates
  `AssetsManifestTest` e `CheckPipelineTest`.
- `fact_01j4nvg7` (médio, ADR-012) títulos de erro de Media/Gallery fixos em **pt_BR** no PHP — chaves
  nos 4 locales (`MediaBatchTest` em `en`).
- `fact_01ls8rto` (médio) bundle `batch.js` fora do orçamento de performance.
- `fact_01az0nhl` (médio, D14) API documentada como `/api/v1/datasets` quando a área **é** o host
  `api.<base>` (caminho real `v1/datasets`; `/api/v1/...` respondia 404).

**Delegado ao `kd`** (médio, sem correção na sessão): `task_01v75xex` (índice único do catálogo),
`task_01htnzob` (reentrega da fila × `concluido`), `task_01gpf5qd` (fronteira `services → services`
no `arch-check`), `task_00zo3k5i` (contrato de leitura por dono no Media). Risco de ambiente:
`risk_01qke8zj` (container `web` com `Caddyfile` antigo passa no gate de texto e só o e2e pega).

Evidência: `make check E2E=1` verde **2×** (22 OK · 0 FAIL · 1 SKIP), e2e 14/15 verdes em `:8090`,
PHPStan `max` limpo, cobertura ≥90%.

## Evidência

`--note "sitemap/JSON-LD; dataset ETag; CSP hash; picture AVIF; media async/quarentena; gallery alt"`.
