---
id: MOD-SUPPORT-NOTICIASIMPORT-API
tipo: modulo
onda: 3
status: concluido
unidades: [MOD-SUPPORT-NOTICIASIMPORT]
fontes:
  - V2/Modules/Support/NoticiasImport/
depende_de: [MOD-SUPPORT-NOTICIASIMPORT, CORE-AUTH, MOD-PUBLICACOES-NOTICIAS]
consumidores: [GRP-SUPPORT, MOD-PUBLICACOES-NOTICIAS]
criticidade: alta
dono: PageImport
---

# Support/NoticiasImport — contrato da API interna

> Contrato normativo do canal máquina-a-máquina. O documento de unidade (efeito, dados, UI,
> configuração) está em [`spec.md`](spec.md); aqui ficam headers, payloads, códigos/corpos exatos,
> semântica de escopo/abilities/consumer/IP/TTL, comando de chaves, idempotência e o contrato
> **esperado** do serviço de Notícias (Onda 4).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Autoriza (ou nega) a execução de um lote de notícias por consumidor interno e devolve o relatório por item, sem nunca expor o segredo armazenado. |
| Entrada | `GET /api/v2/noticiasimport/health`; `POST /api/v2/noticiasimport/run`; headers `X-Access-Key`, `X-Internal-Key`, `Authorization: Bearer`; body `{dir, regional, status, apply}`. |
| Saída | JSON `{status,scope}` ou relatório `{context,summary,items,duration_ms,generated_at}`; erros JSON `{error,message}`. |
| Efeitos colaterais | `last_used_at`, log de auditoria `[InternalApiAuth]`, consumo de cota do throttle; em `apply`, escritas do serviço (notícias, mídia, idempotência, auditoria, cache). |
| Particularidade central | Identidade de **consumidor**, não de unidade: `consumer` recorta a regional, `scope`/`abilities` recortam a operação, `allowed_ips` recorta a origem — e o `consumer` é validado no **controller**, não no middleware. |
| Como se integra | Prefixo automático `api/v2/noticiasimport`; middleware `InternalImportKey` (subclasse de `V2\Core\Middleware\InternalApiAuth`); model alias de `V2\Core\Security\InternalApiKey`; tabela `v2_internal_api_keys`. |
| Se removido, quebra | Coletores externos e suporte perdem o canal; `v2_internal_api_keys` fica sem consumidor. |
| Superfície pública | Dois endpoints, três headers aceitos, um comando de gestão de chaves. |

## 1. Efeito

### 1.1 Superfície exposta

| Método | URI | Nome | Autenticação | Throttle | Resposta |
|---|---|---|---|---|---|
| GET | `/api/v2/noticiasimport/health` | `v2.noticiasimport.api.health` | chave com `noticias:import` | nenhum | `200 {"status":"ok","scope":"<scope configurado>"}` |
| POST | `/api/v2/noticiasimport/run` | `v2.noticiasimport.api.run` | chave com `noticias:import` + `consumer` compatível | `throttle.api:30,1` | relatório (200) ou erro (401/403/422/429) |

Rotas declaradas em `V2/Modules/Support/NoticiasImport/Routes/api.php:12-18`; prefixo e grupo `api`
aplicados por `V2/Core/Providers/V2ServiceProvider.php:309-313`. Somente requisições **autorizadas**
alcançam `throttle.api:30,1`: 401/403 retornam antes do `$next` (`InternalApiAuth.php:42-58,82-96`),
**não** consomem cota e **não** recebem `X-RateLimit-*`, embora cada tentativa grave auditoria
(`:218-232`). `/health` não tem throttle em nenhum caminho.

### 1.2 O que muda no sistema

| Efeito | Fonte |
|---|---|
| `v2_internal_api_keys.last_used_at = now()` (e `updated_at`, por `save()`) em toda requisição autorizada | `V2/Core/Middleware/InternalApiAuth.php:92`; `V2/Core/Security/InternalApiKey.php:226-230` |
| Linha de log `[InternalApiAuth] <resultado>` com prefixo/id/consumidor/IP/path — sem segredo | `InternalApiAuth.php:218-232` |
| Incremento do balde `api_token:<12 primeiros do Bearer>` ou `api_token:ip:<ip>` | `V2/Core/Middleware/ThrottleApiToken.php:29-34,49` |
| Em `apply`, escritas do serviço (ver §5.2) | `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:86-115` |
| Nada é escrito quando `apply` é falso ou inválido | `NoticiasImportService.php:154-164` |

### 1.3 Contrato observável

| Entrada válida | Resultado |
|---|---|
| `GET /health` + chave ativa | `200`, body `{"status":"ok","scope":"noticias:import"}`; sem escrita de domínio |
| `POST /run` + `dir` válido + `apply=false` | `200`, `summary.planned == summary.total`; nenhuma linha em `v2_news`/`v2_news_import_keys` |
| `POST /run` + `dir` válido + `apply=true` | `200`, `summary.created + summary.skipped + summary.failed + summary.rejected == summary.total` |
| `POST /run` + `regional` inexistente | `422`, `context.aborted=true`, `abort_code="regional_not_found"` |

| Entrada inválida | Resultado |
|---|---|
| Sem header de chave | `401 {"error":"missing_key","message":"Chave interna não fornecida."}` |
| Segredo não encontrado | `401 {"error":"invalid_key","message":"Chave interna inválida."}` |
| Chave revogada | `401 {"error":"key_revoked","message":"Chave interna revogada."}` |
| Chave expirada | `401 {"error":"key_expired","message":"Chave interna expirada."}` |
| IP fora de `allowed_ips` | `403 {"error":"ip_not_allowed","message":"IP não autorizado para esta chave."}` |
| Sem `scope`/ability | `403 {"error":"scope_not_allowed","message":"Escopo insuficiente para esta operação."}` |
| `consumer` ≠ `regional` | `403 {"error":"consumer_mismatch","message":"Esta chave não autoriza o consumidor/regional informado."}` |
| `dir` inexistente/fora das raízes | `422 {"error":"invalid_dir","message":"Diretório inválido ou fora da área permitida (storage/app/imports ou TMP)."}` |
| 31 requisições em 60 s | `429 {"error":"too_many_requests","message":"Limite de requisições excedido. Tente novamente em <n> segundos.","retry_after":<n>}` |
| Qualquer outro método | `405` (método não declarado) |

Corpos de erro vêm de `InternalApiAuth::deny()`/`audit()` (`InternalApiAuth.php:201-232`) e
`ThrottleApiToken::handle()` (`ThrottleApiToken.php:36-47`); as mensagens de `invalid_dir` e
`consumer_mismatch` vêm do controller (`NoticiasImportController.php:86-103`). **Todas** as respostas
de erro são JSON, independentemente de `Accept`.

### 1.4 O que este contrato NÃO faz

- Não emite nem devolve chaves por HTTP: só o comando (§4).
- Não valida o lote: campos de item, MIME, tamanho e conteúdo são do serviço de Notícias.
- Não aceita `pending_approval`: o serviço rebaixa para `draft` (§5.1).
- Não usa sessão, cookie ou CSRF no caminho de API (`api` = apenas `bindings`,
  `app/Http/Kernel.php:45-47`).
- Não tem endpoint de status de lote nem de histórico; a única leitura é `/health`.
- Não aplica throttling em `/health`.

## 2. Abordagem

### 2.1 Estratégia

```mermaid
flowchart LR
  A[request api/v2/noticiasimport] --> B[api: bindings]
  B --> C[InternalImportKey]
  C -->|path fora do prefixo| Z[next]
  C --> D[InternalApiAuth::handle]
  D --> E{secret?}
  E -->|null| E401[401 missing_key]
  E -->|== ACCESS_KEY| S[authorizeStatic → bind null]
  E -->|outro| F[findBySecret sha256]
  F -->|null| I401[401 invalid_key]
  F --> G{revogada? expirada? IP? ability?}
  G -->|primeira falha| D4[401/403 específico]
  G -->|ok| H[markUsed + audit + bind]
  H --> T[throttle.api:30,1]
  T --> U[controller]
  S --> T
  U --> V[resolveDir → consumerAllows → service]
  V --> W[200/422 + relatório]
```

### 2.2 Mecânica passo a passo

1. O provider registra `Routes/api.php` sob `api/v2/noticiasimport` com o grupo `api`
   (`V2ServiceProvider.php:309-313`).
2. `InternalImportKey::handle()` só delega se o path for o prefixo `api/v2/noticiasimport` ou um
   descendente (`Middleware/InternalImportKey.php:18,31-33`); o escopo exigido é
   `config('v2.internal_import_keys.scope_noticias', 'noticias:import')` (`:43-46`).
3. `InternalApiAuth::handle()` extrai o segredo nesta ordem: `config('v2.internal_api.header')`
   (default `X-Access-Key`), `X-Access-Key`, `X-Internal-Key`, `Authorization: Bearer`
   (`InternalApiAuth.php:124-141`).
4. Se o segredo é a `ACCESS_KEY` estática (comparação `hash_equals`), autoriza sem banco e vincula
   `internal_api_key = null` (`:47-49,110-116`).
5. Caso contrário, busca por `sha256` e valida na ordem revogada → expirada → IP → ability
   (`:51-58,72-90`).
6. Em sucesso: `markUsed()`, log `authorized`, atributos `internal_api_key`, `internal_api_static`,
   `internal_import_key` (`:92-94,184-191`).
7. `throttle.api:30,1` conta a requisição (após autenticar) e adiciona `X-RateLimit-Limit`/
   `X-RateLimit-Remaining` na resposta (`ThrottleApiToken.php:51-57`). Como o middleware de chave
   retorna antes do `$next`, respostas 401/403 **não** incrementam o balde, não trazem
   `X-RateLimit-*` e ainda assim geram auditoria (`InternalApiAuth.php:42-58,82-96,218-232`) —
   falhas de autenticação são ilimitadas.
8. O controller resolve `dir` **independentemente da chave e do consumidor** (ver §5.2 e CT-10),
   depois `regional` e `consumer`, chama o serviço e traduz `context.aborted` em `422`
   (`NoticiasImportController.php:82-115`).

### 2.3 Decisões de design e por quê

- **Core canônico, alias local** `[C]`: a credencial e a autenticação vivem em
  `V2/Core/Security/InternalApiKey` e `V2/Core/Middleware/InternalApiAuth`; o módulo só tem uma
  subclasse de 5 linhas efetivas e um model vazio (`Middleware/InternalImportKey.php:16-46`;
  `Models/InternalImportKey.php:20-23`). O objetivo é permitir outra API interna reutilizar a mesma
  credencial sem copiar validação.
- **Gate por prefixo no wrapper** `[C]`: protege contra uso acidental do middleware em um grupo maior,
  sem afetar o restante do `api`.
- **`consumer` fora do middleware** `[C]`: o middleware não conhece o payload; só o controller sabe
  qual `regional` foi pedida. Correção de `SEC-G1-05` (§3.2).
- **Prefixos de segredo para rate-limit/auditoria** `[C]`: `key_prefix` = 12 primeiros caracteres;
  não é único por design e não serve como identidade.
- **Revogação lógica** `[C]`: `revoked_at` em vez de delete; a linha continua auditável, e a chave é
  rejeitada antes de qualquer outra checagem.
- **Throttle na rota, depois da autenticação** `[C]`: 401/403 nunca chegam ao limiter, então o balde
  só mede tráfego autorizado (`InternalApiAuth.php:42-58,82-96`; `ThrottleApiToken.php:24-49`).
  Limitação conhecida (`SEC-G4-06`): não há proteção de brute-force por IP; a recomendação é um
  limiter de IP global antes da autenticação, mantendo o balde por chave depois.
- **Diretório não vinculado ao consumidor** `[C]`: `resolveDir()` roda antes de ler a chave e só
  compara com as raízes (`NoticiasImportController.php:84-91,124-141`); o `consumer` autoriza a sigla
  do payload, jamais o caminho (`SEC-G4-05`, ver §5.2).

### 2.4 Trechos de código pivô

```php
// V2/Core/Middleware/InternalApiAuth.php:82-96 — ordem de negação e efeitos do sucesso.
if (!$key->ipAllowed($request->ip())) {
    $this->audit('warning', $key, $request, 'ip_not_allowed');
    return $this->deny(403, 'ip_not_allowed', 'IP não autorizado para esta chave.');
}

if (!$key->hasAbility($scope)) {
    $this->audit('warning', $key, $request, 'scope_not_allowed');
    return $this->deny(403, 'scope_not_allowed', 'Escopo insuficiente para esta operação.');
}

$key->markUsed();
$this->audit('info', $key, $request, 'authorized');
$this->bind($request, $key, false);

return $next($request);
```

O IP é checado **antes** da ability: uma chave correta usada de IP errado recebe `ip_not_allowed`, não
`scope_not_allowed`. `markUsed()` roda em toda autorização, então `/health` também escreve.

```php
// V2/Core/Security/InternalApiKey.php:187-204 — corpo de hasAbility()
// (assinatura em :185); concessão de escopo.
    if ($this->scopeMatches($scope)) {
        return true;
    }

    foreach ((array) $this->abilities as $ability) {
        $ability = (string) $ability;

        if ($ability === '*' || hash_equals($ability, $scope)) {
            return true;
        }

        if (substr($ability, -2) === ':*' && strpos($scope, substr($ability, 0, -1)) === 0) {
            return true;
        }
    }

    return false;
}
```

`scope` exato sempre autoriza, mesmo que `abilities` exista. `noticias:*` casa
`substr('noticias:*', 0, -1) = 'noticias:'` no início do escopo pedido. `hash_equals` é usado para
evitar timing leak; `scopeMatches` compara o `scope` com `hash_equals` (`:174-177`).

```php
// V2/Core/Security/InternalApiKey.php:212-219 — recorte de consumidor (validado no controller).
public function consumerAllows($consumer): bool
{
    if ($this->consumer === null || $this->consumer === '') {
        return true;
    }

    return hash_equals($this->consumer, strtoupper((string) $consumer));
}
```

`null`/vazio = irrestrito. A comparação é case-sensitive contra o valor em maiúsculas, por isso
`issue()` grava `strtoupper(...)` (`:100`). Esta função **não** é chamada pelo middleware: o único
chamador é `NoticiasImportController.php:98`.

```php
// V2/Core/Middleware/InternalApiAuth.php:184-191 — o que o controller recebe.
protected function bind(Request $request, $key, bool $static)
{
    $request->attributes->set('internal_api_key', $key);
    $request->attributes->set('internal_api_static', $static);

    // Compatibilidade com o middleware/módulo de importação de notícias.
    $request->attributes->set('internal_import_key', $key);
}
```

Com `ACCESS_KEY` estática, `$key` é `null`; com chave persistente, é a instância de
`InternalApiKey` **sem o segredo**. O controller usa apenas `internal_api_key`.

```php
// V2/Modules/Support/NoticiasImport/Console/Commands/NoticiasImportKeyCommand.php:17-24 —
// superfície do comando (uma chave por invocação).
protected $signature = 'v2:noticias-import-key
    {--name= : Nome/descrição da chave}
    {--consumer= : Consumidor/alvo (ex.: PE). Vazio = irrestrito}
    {--abilities= : CSV de habilidades (ex.: noticias:import,noticias:*). Vazio = usa o scope}
    {--ttl=90 : Validade em dias (0 = sem expiração)}
    {--ips= : CSV de IPs autorizados (vazio = qualquer IP)}
    {--revoke= : UUID da chave a revogar}
    {--list : Lista as chaves existentes}';
```

Precedência: `--list` antes de `--revoke`; sem nenhum dos dois, emite (`:34-42`). `--name` vazio
aborta com `Informe --name=<descrição da chave>.` e código de saída `1` (`:52-57`).

### 2.5 Algoritmos e custos

- Uma consulta indexada por requisição (`key_hash` unique) e um `UPDATE` (`markUsed`).
- Sem cache de chave: revogação/expirada têm efeito imediato.
- `--list` carrega todas as chaves sem paginação (`:112-133`).
- `POST /run` é síncrono; o custo é o do lote (uma transação por item em `apply`).

## 3. Particularidades

### 3.1 O que só existe aqui

- **Três credenciais aceitas para o mesmo endpoint** (header configurável, header legado e Bearer).
- **`consumer` como alvo regional** — conceito que não existe na API das regionais nem em outros
  middlewares.
- **`/health` autenticado mas sem throttle** e com `scope` **configurado**, não o efetivo da chave.
- **Chave estática que ignora `consumer`** (consequência direta do `bind(null)`).

### 3.2 Correção do achado `SEC-G1-05` (normativo)

| Afirmação anterior | Realidade verificada |
|---|---|
| “Nenhum middleware chama `consumerAllows()`; não há enforcement.” | Há enforcement, mas no **controller**: `NoticiasImportController.php:98-103` chama `consumerAllows($regional)` e responde `403 consumer_mismatch`. |
| “O middleware valida o consumidor.” | Não. `InternalApiAuth::authorize()` valida revogação, expiração, IP e ability — nunca `consumer` (`InternalApiAuth.php:70-97`). |
| — | Com a chave estática, `authorizeStatic()` vincula `key = null` (`:110-116`); o controller faz `$key && !$key->consumerAllows(...)`, a condição é falsa e `defaultRegional` cai em `'PE'` (`Controllers/NoticiasImportController.php:94-95`). |

Consequências para a reimplementação: (a) `GET /health` **não** valida consumidor; (b) a chave
estática pode operar qualquer regional existente, sempre com default `PE`; (c) mover a checagem para
o middleware mudaria o contrato observável e exigiria que o middleware lesse o body.

### 3.3 Invariantes do contrato

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| CT-NOTICIASIMPORT-01 | Ordem de negação: sem segredo → estática → inexistente → revogada → expirada → IP → ability. | Código de erro determinístico por falha. | `InternalApiAuth.php:42-90` |
| CT-NOTICIASIMPORT-02 | `dir` é validado antes de `consumer`. | `invalid_dir` (422) vence `consumer_mismatch`. | `NoticiasImportController.php:84-103` |
| CT-NOTICIASIMPORT-03 | `regional` ausente = `consumer` da chave; sem consumer = `'PE'`; sempre `strtoupper`. | Payload mínimo `{dir}` funciona. | `:95-96` |
| CT-NOTICIASIMPORT-04 | `apply` só é verdadeiro para `1/true/on/yes`. | `apply=2`, `apply=sim` → dry-run silencioso. | `:108` |
| CT-NOTICIASIMPORT-05 | `status` inválido ou `pending_approval` → `draft`. | Resposta nunca falha por status. | `NoticiasImportService.php:129-137` |
| CT-NOTICIASIMPORT-06 | `context.aborted` ⇒ HTTP 422; caso contrário 200. | Não existe 404 para regional inexistente. | `NoticiasImportController.php:113-115` |
| CT-NOTICIASIMPORT-07 | Segredo nunca é logado nem retornado. | Auditoria usa `key_prefix`/`key_id`. | `InternalApiAuth.php:218-232` |
| CT-NOTICIASIMPORT-08 | Chave revogada não é apagada. | `--list` mostra `revoked_at`. | `InternalApiKey.php:237-241` |
| CT-NOTICIASIMPORT-09 | `X-RateLimit-*` presentes em respostas bem-sucedidas e em `429`. | Cliente pode reagir sem endpoint extra. | `ThrottleApiToken.php:36-57` |
| CT-NOTICIASIMPORT-10 | `consumer` não restringe `dir`: qualquer caminho sob `storage/app/imports`/`TMP` vale para qualquer chave. | Chave `PE` lê/aplica o lote de `RJ` (`SEC-G4-05`). | `NoticiasImportController.php:84-111,124-141`; `NoticiasImportReport.php:97-116` |
| CT-NOTICIASIMPORT-11 | 401/403 não passam pelo `throttle.api` e não recebem `X-RateLimit-*`. | Falhas ilimitadas, uma auditoria por tentativa (`SEC-G4-06`). | `InternalApiAuth.php:42-58,82-96,218-232`; `Routes/api.php:16-18` |

### 3.4 Escopo, abilities, consumer, IP, TTL e revogação

| Atributo | Semântica | Default | Observações |
|---|---|---|---|
| `scope` | string exata exigida pelo middleware; também é a identidade “legada” da chave | `noticias:import` (`config('v2.internal_import_keys.scope_noticias')`) | `scope` exato concede mesmo com `abilities` restritivas |
| `abilities` | lista JSON; aceita valor exato, prefixo `dominio:*` e `*` | `null` (usa só `scope`) | `noticias:*` autoriza `noticias:import`; `*` autoriza qualquer escopo futuro |
| `consumer` | sigla de `UnidadeRegional` autorizada; `null`/vazio = irrestrito | `null` | validado **no controller**, com 403 `consumer_mismatch`; não há unique por consumer |
| `allowed_ips` | lista JSON de IPs exatos; vazio = qualquer | `null` | `in_array(..., true)`; sem CIDR; depende de `TrustProxies` (`app/Http/Middleware/TrustProxies.php:15,23-27`) |
| `expires_at` | instante de expiração | `--ttl=90` dias; `0` = nunca | comparação `isPast()` no fuso da aplicação |
| `revoked_at` | revogação terminal | `null` | checada antes de expiração/IP/ability |
| `last_used_at` | último uso autorizado | `null` | escrito inclusive por `/health` |

### 3.5 Por que NÃO unificar com a API das Unidades Regionais

| Dimensão | API das Unidades Regionais | API interna (este módulo) |
|---|---|---|
| Identidade | unidade regional (`v2_unidade_regional.api_token_hash`) | consumidor máquina-a-máquina (`v2_internal_api_keys`) |
| Transporte da credencial | `Authorization: Bearer` apenas | `X-Access-Key`, `X-Internal-Key` ou Bearer |
| Onde autentica | **dentro do controller** (`authenticateToken()`) | middleware de rota reutilizável |
| Escopo | implícito: a própria unidade; escrita nasce `pending_approval` | `noticias:import` + `abilities`, alvo via `consumer` |
| Recorte de alvo | um por credencial, com FK lógica para a unidade | opcional (`consumer`) e validado no controller |
| Ciclo de vida | `generateApiToken`/`revokeApiToken` no model da unidade, TTL de 365 dias | `issue`/`revoke` em tabela própria, TTL default 90 |
| Propósito | regional **cria/edita** no portal da Nacional | suporte **injeta lote coletado** no ponto inicial |
| Throttle | `throttle.api:30,1` + Laravel `throttle:100,1` | `throttle.api:30,1` |

Fontes: `V2/Modules/Conexoes/UnidadeRegional/Routes/api.php:3-31`;
`Controllers/UnidadeRegionalNewsApiController.php:446-474`; `Models/UnidadeRegional.php:252-300`;
`InternalApiAuth.php:124-154`.

Motivos para não unificar: (1) **modelos de confiança distintos** — a unidade é um cliente externo
com escopo natural (a si mesma), o consumidor interno é operação de bootstrap que pode escolher o
alvo; (2) **contrato de cliente distinto** — trocar o Bearer das regionais por `X-Access-Key`
quebraria todas as integrações existentes e o `RegionalRestrictMiddleware`; (3) **semântica de
domínio distinta** — a regional escreve `pending_approval` na própria unidade, o importador grava
`draft`/`published` em lote com idempotência; (4) **ciclo de vida distinto** — a unidade revoga
zerando colunas da própria linha, a chave interna revoga com data e mantém histórico; (5) **extensão**
— `abilities` e `allowed_ips` não fazem sentido para uma unidade. Unificar exigiria mover auth de
dentro de 4+ controllers para um middleware e migrar tokens sem ganho funcional.

### 3.6 Casos-limite do contrato

- **Dois headers preenchidos**: vence o primeiro não vazio na ordem configurado → `X-Access-Key` →
  `X-Internal-Key` (`InternalApiAuth.php:126-133`); os demais são ignorados.
- **Header com espaços**: `trim` é aplicado (`:130,137`).
- **Segredo vazio**: tratado como ausente (`trim(...) !== ''`), logo `missing_key`.
- **`X-Access-Key` igual à `ACCESS_KEY` e também existente no banco**: a checagem estática vem antes
  do banco (`:47-49`), então prevalece o caminho estático.
- **`allowed_ips` com IP de proxy**: `$request->ip()` reflete `X-Forwarded-For` quando
  `TrustProxies` confia no proxy; allow-list é defesa secundária.
- **Chave expirada cujo `expires_at` é exatamente `now()`**: `isPast()` é estrito (`< now`), então
  ainda é válida naquele instante.
- **`regional` com espaços** (`" pe "`): não há `trim` no controller; `strtoupper(" PE ")` não casa
  com `consumer` e/ou não é encontrado pelo serviço (que faz `trim` em `findRegional`,
  `NoticiasImportService.php:529-532`), resultando em 403 ou 422.
- **`apply=true` sem itens**: `200` com `summary.total=0`, nenhuma escrita e nenhuma limpeza de cache.
- **`dir` válido sem subpastas `noticia*`**: `200` com `total=0` (o normalizador faz `glob` e devolve
  lista vazia, `NoticiasImportNormalizer.php:50-61`).
- **Rate limit**: o balde é compartilhado entre chaves usadas via header no mesmo IP; só Bearer
  individualiza (`ThrottleApiToken.php:29-34`).
- **Lote de outra regional**: chave `consumer=PE` + `dir` do lote de `RJ` é aceito; a gravação (ou o
  relatório de leitura) segue a `regional` do payload, não o `consumer` (CT-10; §5.2).
- **Tentativas inválidas**: repetição de `missing_key`/`invalid_key`/`ip_not_allowed` nunca retorna
  `429`; cada uma grava log de auditoria (CT-11).
- **`--ttl` inválido**: `(int)` transforma `abc` em `0` (perpétuo) e `-1` em data passada (chave já
  expirada) — ver §4.

## 4. Gestão de chaves (comando)

| Flag | Obrigatória | Default | Efeito |
|---|---|---|---|
| `--name=` | sim (para emitir) | — | descrição; vazio → erro e exit `1` |
| `--consumer=` | não | vazio → `null` | grava `strtoupper(valor)` |
| `--abilities=` | não | vazio → `null` | CSV; `trim` e remove vazios |
| `--ttl=` | não | `90` | `0` → `expires_at = null` (perpétuo). Valor não inteiro é convertido por `(int)` e vira `0` (perpétuo); negativo cria chave já expirada (`SEC-G4-07`) |
| `--ips=` | não | vazio → `null` | CSV de IPs exatos |
| `--revoke=` | não | — | UUID; não encontrado → erro e exit `1` |
| `--list` | não | — | tabela `id, name, consumer, prefix, scope, expires_at, revoked_at, last_used_at` |

Comportamento de saída (emissão): bloco com `id`, `prefix`, `consumer`, `escopo`, `abilities`,
`expira` e, em seguida, o aviso `SEGREDO (exibido uma única vez — guarde agora):` seguido do segredo
de 64 hex (`NoticiasImportKeyCommand.php:72-81`). O segredo é `bin2hex(random_bytes(32))`
(`InternalApiKey.php:96`); só o `sha256` fica no banco.

Precedência `--list` > `--revoke` > emissão (`:34-42`). Revogação imprime
`Chave revogada: <prefix> (<uuid>)` (`:101-102`). `--list` não filtra por escopo, não pagina e não
mostra soft-deleted.

**Validação de `--ttl` (`SEC-G4-07`).** O código atual faz apenas `(int) $this->option('ttl')`
(`:64`) e `InternalApiKey::issue()` trata `!empty($ttl_days)` como “tem TTL”
(`InternalApiKey.php:105`): `--ttl=abc` → `0` → `expires_at = null` (**chave perpétua**) e
`--ttl=-1` → data no passado (**chave nasce expirada**). A reimplementação deve validar
`FILTER_VALIDATE_INT` e exigir `>= 0`, abortando com
`Informe --ttl=<dias inteiros >= 0> (0 = sem expiração).` e exit `1`; **somente** o literal `0` pode
produzir perpetuidade.

## 5. Rotas e contratos HTTP e integrabilidade

| Método | URI | Nome | Middleware efetivo | Controller@ação | Fonte |
|---|---|---|---|---|---|
| GET | `/api/v2/noticiasimport/health` | `v2.noticiasimport.api.health` | `api`, `bindings`, `InternalImportKey` | `NoticiasImportController@apiHealth` | `V2/Modules/Support/NoticiasImport/Routes/api.php:13-14` |
| POST | `/api/v2/noticiasimport/run` | `v2.noticiasimport.api.run` | `api`, `bindings`, `InternalImportKey`, `throttle.api:30,1` | `NoticiasImportController@apiRun` | `Routes/api.php:16-18` |
| GET | `/cms/importacao-noticias` | `v2.noticiasimport.cms.index` | `web`, `module.active:NoticiasImport`, `CmsAuthenticate` → `V2PermissionMiddleware` | `NoticiasImportController@index` | `Routes/cms.php:10-14` |
| POST | `/cms/importacao-noticias/run` | `v2.noticiasimport.cms.run` | idem + `VerifyCsrfToken` | `NoticiasImportController@run` | `Routes/cms.php:15` |

O prefixo `api/v2/noticiasimport` não aparece no arquivo de rotas: é aplicado pelo provider
(`V2/Core/Providers/V2ServiceProvider.php:309-313`). A ordem efetiva no canal de API é
`bindings` → `InternalImportKey` → `throttle.api` → controller (middleware do grupo antes do
middleware da rota, `vendor/laravel/framework/src/Illuminate/Routing/Route.php:767-779`).

### 5.1 Headers de entrada

| Header | Obrigatório | Formato | Observação |
|---|---|---|---|
| `X-Access-Key` (nome configurável) | um dos três | segredo de 64 hex | default de `config('v2.internal_api.header')` |
| `X-Internal-Key` | um dos três | idem | legado, mantido para clientes antigos |
| `Authorization` | um dos três | `Bearer <segredo>` | também usado pelo throttle para individualizar o balde |
| `Content-Type` | não | `application/json` ou form | o controller usa `input()`, aceita ambos |
| `Accept` | não | `application/json` | erros são JSON de qualquer forma |

### 5.2 Payload de `POST /run`

| Campo | Tipo | Obrigatório | Default | Efeito |
|---|---|---|---|---|
| `dir` | string | sim (na prática) | `''` → `422 invalid_dir` | diretório do lote; precisa existir e estar sob `storage/app/imports` ou `TMP`; **não** é validado contra o `consumer` |
| `regional` | string | não | `consumer` da chave, senão `PE` | sigla da `UnidadeRegional`; `strtoupper`; não precisa ser a do consumer se o consumer for `null` |
| `status` | string | não | `draft` | `draft`/`published`; qualquer outro valor (inclusive `pending_approval`) vira `draft` |
| `apply` | bool-like | não | `false` | `false` = dry-run; `true` grava |

Sem `FormRequest`: não há mensagens de validação de campo; erros de payload são
`invalid_dir` (422) ou o relatório abortado.

**`dir` × `consumer` (`SEC-G4-05`).** `resolveDir()` é chamado **antes** de ler chave/consumidor
(`NoticiasImportController.php:84-91`) e só compara o caminho real com as duas raízes
(`:124-141`). Não há vínculo path↔consumer: uma chave emitida para `PE` pode apontar para o lote de
`RJ`; em dry-run o relatório expõe `title`, `source_url`, `cover`, `body_images` e `tags`
(`NoticiasImportReport.php:97-116`), e em `apply` as notícias são gravadas na `regional` pedida no
payload. Recomendação: derivar o diretório do consumidor (`storage/app/imports/<consumer>/…`) ou
manter allow-list de diretórios por chave, e **auditar o `dir` resolvido** — hoje ele só aparece no
relatório de resposta, não no log de autenticação (`InternalApiAuth.php:218-232`).

### 5.3 Resposta de sucesso (`200`)

| Chave | Tipo | Conteúdo |
|---|---|---|
| `context.dir` | string | caminho absoluto resolvido |
| `context.regional` | string | sigla resolvida |
| `context.status` | string | status normalizado |
| `context.apply` | bool | modo efetivo |
| `summary.total` | int | itens avaliados |
| `summary.counts` | objeto | contagem por ação |
| `summary.created/planned/skipped/rejected/failed` | int | atalhos de `counts` |
| `items[]` | array | `name`, `title`, `content_date`, `status`, `regional`, `category_slug`, `category_id`, `category_exists`, `cover`, `body_images`, `tags`, `source_url`, `import_key_hash`, `warnings`, `errors`, e `action` (+ `message`, `news_id`, `slug` quando aplicável) |
| `duration_ms` | int | duração |
| `generated_at` | string | ISO-8601 |

Itens em `apply` têm `action` ∈ `created|skipped|rejected|failed`; em dry-run ∈
`planned|rejected`. `action` é adicionado pelo serviço, não pelo relatório
(`NoticiasImportService.php:154-164,241-249`; `NoticiasImportReport.php:97-116`).

### 5.4 Resposta de aborto (`422`)

```json
{
  "context": {
    "dir": "/var/www/storage/app/imports/lote-x",
    "regional": "RJ",
    "status": "draft",
    "apply": false,
    "aborted": true,
    "abort_code": "regional_not_found",
    "abort_message": "Unidade regional não encontrada: RJ"
  },
  "summary": {"total": 0, "counts": [], "created": 0, "planned": 0, "skipped": 0, "rejected": 0, "failed": 0},
  "items": [],
  "duration_ms": 1,
  "generated_at": "2026-10-21T12:00:00-03:00"
}
```

O `422` é decidido por `empty($report['context']['aborted'])`
(`NoticiasImportController.php:113-115`); não há campo `error` neste corpo — clientes precisam ler
`context.abort_code`.

### 5.5 Receitas de uso

**Health check:**

```bash
curl -sS -H "X-Access-Key: $INTERNAL_KEY" \
  https://cms.example.org/api/v2/noticiasimport/health
# 200 {"status":"ok","scope":"noticias:import"}
```

**Dry-run apontando para um lote absoluto:**

```bash
curl -sS -X POST https://cms.example.org/api/v2/noticiasimport/run \
  -H "X-Access-Key: $INTERNAL_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"dir":"/var/www/storage/app/imports/sucesu_pe_noticias","regional":"PE","status":"draft","apply":false}'
# 200 com summary.planned == summary.total e items[].import_key_hash preenchido
```

**Aplicar depois de revisar o dry-run:**

```bash
curl -sS -X POST https://cms.example.org/api/v2/noticiasimport/run \
  -H "Authorization: Bearer $INTERNAL_KEY" \
  -H "Content-Type: application/json" \
  -d '{"dir":"/var/www/storage/app/imports/sucesu_pe_noticias","apply":true}'
# 200; summary.created + summary.skipped == summary.total em reexecuções
```

**Emitir e revogar:**

```bash
php artisan v2:noticias-import-key --name="Coletor PE" --consumer=PE --ttl=90 --ips=203.0.113.10
php artisan v2:noticias-import-key --revoke=3f1c2d9e-...
```

### 5.6 Anti-uso

1. **Enviar `regional` de outra unidade achando que o `scope` restringe** — `scope` restringe a
   operação, não o alvo; sem `consumer`, qualquer sigla válida é aceita (e a chave estática sempre
   assume `PE` como default).
2. **Confiar em `allowed_ips` como fronteira de rede** — o IP vem de `$request->ip()`, influenciável
   por headers de proxy; a defesa primária é o segredo.
3. **Usar `GET /health` como prova de que o lote será aceito** — health só valida a chave e devolve o
   escopo configurado; `dir`, `regional` e permissões do serviço só são exercitados no `/run`.

### 5.7 Custos e limites

- 30 requisições/minuto por balde (Bearer ou IP), contadas **apenas para requisições autorizadas**;
  `/health` é ilimitado.
- 401/403 não consomem cota e não têm `X-RateLimit-*`; cada falha grava auditoria, então tentativas
  repetidas são um vetor de brute-force/log-flood. Recomendação: limiter global por IP **antes** da
  autenticação, preservando o balde por chave depois dela.
- Resposta síncrona; lote grande → tempo de execução do PHP, sem fila.
- Sem paginação/streaming do relatório: o JSON cresce linearmente com o número de itens.
- Uma consulta de chave + um `UPDATE` por requisição, mesmo em `/health`.
- `429` inclui `Retry-After` e `retry_after` em segundos (`ThrottleApiToken.php:36-47`).

## 6. Dados e modelo de domínio

### 6.1 `v2_internal_api_keys` (deste módulo)

PK `id` (uuid), `name`, `scope`, `key_hash` (unique, sha256), `key_prefix`, `allowed_ips` (jsonb),
`expires_at`, `revoked_at`, `last_used_at`, `created_by`, timestamps, `deleted_at`, `consumer`
(varchar 32, indexado) e `abilities` (jsonb). Colunas completas e casts em
[`spec.md`](spec.md) §6.1; migrations em
`Migrations/2026_09_17_000002_create_v2_internal_api_keys_table.php:28-41` e
`Migrations/2026_09_18_000003_add_consumer_abilities_to_v2_internal_api_keys.php:30-38`.
Não há FK para `users` nem para `v2_unidade_regionais`.

### 6.2 Contrato esperado de `NoticiasImportService` (Onda 4)

Este módulo **não implementa** o serviço; ele é dependência injetada de
`V2\Modules\Publicacoes\Noticias\Services\NoticiasImportService` (unidade
`MOD-PUBLICACOES-NOTICIAS`). O que o transporte assume:

| Item | Contrato observado | Fonte |
|---|---|---|
| Assinatura | `run(string $dir, array $options = []): array` | `NoticiasImportService.php:86` |
| `options.regional` | sigla; default `PE`; resolvida por `LOWER(acronym)` | `:129,529-532` |
| `options.status` | default `draft`; inválido/`pending_approval` → `draft` | `:131-137` |
| `options.apply` | `!empty()`; default dry-run | `:135` |
| `options.author_id` / `author_email` | usados no log/ator; `null` para API sem sessão | `:136-137,564-575` |
| `$dir` | diretório raiz; o serviço espera subpastas `noticia*` | `NoticiasImportNormalizer.php:50-61` |
| Retorno | relatório serializável (`context`, `summary`, `items`, `duration_ms`, `generated_at`) | `NoticiasImportReport.php:97-116` |
| Aborto | `context.aborted=true`, `abort_code`, `abort_message`; nenhum item processado | `NoticiasImportService.php:100-105` |
| Ações | `planned` (dry-run), `rejected` (item inválido), `skipped` (já importado), `created`, `failed` | `:30-31,154-164,219-250` |
| Escrita | `apply=true` e chave inexistente → notícia + mídia + tags + `v2_news_import_keys`, em transação por item | `:227-239,451-459` |
| Efeitos pós-lote | `v2:clear-page-cache` se houve `created`; log `[NoticiasImport] Execução concluída`; `ActivityLogger` `imported` por item | `:489-500,543-559,244-249` |
| Falha de item | rollback do item, `action=failed`, `message` da exceção; o lote continua | `:233-239` |
| Categoria/regional | derivadas no servidor (`regional-<sigla>`), nunca do payload | `:17-26,177-187` |
| Dependências | `NoticiasImportNormalizer`, `GalleryService`, `RegionalIdentityService`, `ActivityLoggerService` | `:67-77` |

Precondições para o transporte: a `UnidadeRegional` deve existir (senão 422), `dir` deve existir e
estar confinado, e o container deve resolver as quatro dependências. O transporte **não** deve
interpretar itens, recalcular `import_key_hash` nem escrever em `v2_news`.

### 6.3 `v2_news_import_keys` (contrato de idempotência, unidade Notícias)

| Coluna | Tipo | Nota |
|---|---|---|
| `id` | uuid | PK; gerado por `v2_uuid()` no hook do model |
| `key_hash` | char(64) | **unique**; `sha256("regional:<sigla-minuscula>:<source_url>")` |
| `regional` | varchar(10) | indexado; sigla em minúsculas |
| `source_url` | text | URL de origem do item |
| `news_id` | varchar(36) | nullable, indexado; FK lógica para `v2_news.id` |
| `created_at`/`updated_at` | timestamp | — |

Fonte: `V2/Modules/Publicacoes/Noticias/Migrations/2026_09_17_000001_create_v2_news_import_keys_table.php:29-36`;
model `NewsImportKey.php:16-38,41-50`; escrita em `NoticiasImportService.php:451-459`.

Contrato: a chave é calculada para **todo** item, inclusive em dry-run (`:205-208,192`), mas só é
gravada em `apply` após a criação da notícia; se `key_hash` já existe, o item vira `skipped` com
`message = "Já importado anteriormente (idempotência)."` (`:221-225`). Reexecutar o mesmo lote é,
portanto, seguro — e é o teste de paridade do canal. A unidade dona desta tabela é
`MOD-PUBLICACOES-NOTICIAS`; a linha correspondente em `tabelas.csv` é emitida por ela, não por este
documento.

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Ativa: issue()
  Ativa --> Expirada: expires_at < now
  Ativa --> Revogada: revoke()
  Expirada --> Revogada: revoke()
  Ativa --> Ativa: markUsed()
  Expirada --> Expirada: 401 key_expired
  Revogada --> Revogada: 401 key_revoked
```

`FL-NOTICIASIMPORT-04` — chamada autorizada: `bind` → throttle → controller → serviço; o relatório
sai com `200` (ou `422` quando `context.aborted`). `FL-NOTICIASIMPORT-05` — chamada negada: nenhum
acesso ao controller, nenhum efeito de domínio; apenas log/auditoria e, para 429, consumo de balde.

Reexecução: `apply` repetido gera `skipped` para todos os itens já vistos; novos itens são
adicionados. Não há lock de lote: duas execuções concorrentes podem ambas passar por `keyExists()`
antes do `INSERT` e uma falhar na unique de `key_hash` → item `failed` (não corrompe dados, mas
produz ruído).

## 8. UI CMS

O contrato HTTP não tem tela própria. O único consumidor de UI é a página de suporte, cujo
`POST /cms/importacao-noticias/run` usa **CSRF** (`X-CSRF-TOKEN` do meta) e envia
`{dir, regional, status, apply}` com `apply` `1|0` (`Views/cms/index.blade.php:106-123`). Diferenças
relevantes para o contrato: a rota CMS autentica por sessão + e-mail de suporte, não por chave; e a
resposta é a mesma estrutura JSON do `/run` da API. Campos, DOM e mensagens estão em
[`spec.md`](spec.md) §8.

## 9. UI Portal

Não se aplica: não há rota ou view pública associada a este contrato.

## 10. Configuração, permissões e flags

| Chave | Default | Efeito no contrato | Fonte |
|---|---|---|---|
| `v2.internal_api.header` | `X-Access-Key` | nome do header principal aceito | `InternalApiAuth.php:171-174` |
| `v2.internal_api.access_key` | vazio | se preenchida, autoriza sem banco e ignora `consumer` | `:149-154` |
| `v2.internal_api.default_scope` | `noticias:import` | escopo do `internal.api` genérico (não deste wrapper) | `:161-164` |
| `v2.internal_import_keys.scope_noticias` | `noticias:import` | escopo exigido/emitido por este módulo | `Middleware/InternalImportKey.php:43-46` |
| `v2.internal_api.ttl_days` / `v2.internal_api.max_attempts` | 90 / 30 | **sem uso** | `config/v2.php:34-35` |
| `v2.internal_import_keys.ttl_days` / `.max_attempts` | 90 / 30 | **sem uso** (comando fixa 90; rota fixa 30) | `config/v2.php:41-43` |
| `v2.support.email` | `suporte@maxima.inf.br` | `created_by` e gate do CMS | `NoticiasImportKeyCommand.php:66` |

Autorização: `noticias:import` (escopo/ability) e `consumer` (controller). Não há role Sentinel
envolvida no canal de API. Flags de módulo afetam apenas as rotas CMS
(`module.active:NoticiasImport`), não a API.

## 11. Observabilidade, erros e limites

| Resultado auditado | Nível | HTTP | Efeito colateral |
|---|---|---|---|
| `missing_key` | warning | 401 | log |
| `invalid_key` | warning | 401 | log |
| `key_revoked` | warning | 401 | log |
| `key_expired` | warning | 401 | log |
| `ip_not_allowed` | warning | 403 | log |
| `scope_not_allowed` | warning | 403 | log |
| `authorized` | info | — | log + `last_used_at` |
| `authorized_static` | info | — | log; sem banco |
| `consumer_mismatch` | — | 403 | **sem log** (só resposta do controller) |
| `invalid_dir` | — | 422 | **sem log** |
| `too_many_requests` | — | 429 | balde incrementado |

Campos do log de autenticação: `module=internal_api`, `action=internal_key_auth`, `result`,
`key_prefix`, `key_id`, `consumer`, `ip`, `method`, `path`, `ts` (`InternalApiAuth.php:218-232`).
Nenhum campo contém o segredo. **401/403 não entram no balde** nem recebem `X-RateLimit-*`; cada
tentativa gera uma linha de auditoria, de modo que falhas são ilimitadas (`SEC-G4-06`). Limites:
30/min apenas para requisições autorizadas, sem limite de payload no controller, sem timeout próprio,
sem paginação em `--list`. O `dir` resolvido **não** é auditado (`SEC-G4-05`).

## 12. Seeds e dados iniciais

Não se aplica: nenhuma chave é semeada; `v2_internal_api_keys` nasce vazia e só recebe linhas via
`v2:noticias-import-key`. O `DatabaseSeeder` não toca esta tabela.

## 13. Lacunas

- **LG-NOTICIASIMPORT-07** — Não há versão documentada do contrato para clientes externos (OpenAPI
  do módulo não existe; o da API regional cobre só `/api/v2/unidaderegional/*`,
  `UnidadeRegional/Routes/api.php:37-38`).
- **LG-NOTICIASIMPORT-08** — Não foi definido se `consumer` deve ser único por chave ativa; hoje
  duas chaves podem ter `consumer=PE` (`Migrations/...consumer...:31-33`).
- **LG-NOTICIASIMPORT-09** — Comportamento de concorrência do lote não é testado; a unique de
  `v2_news_import_keys.key_hash` é a única proteção e converte a corrida em item `failed`.
- **LG-NOTICIASIMPORT-10** — Não há vínculo entre chave e diretório nem auditoria do `dir` executado;
  descobrir qual lote foi importado por qual chave exige cruzar o log do serviço com o relatório.
- **LG-NOTICIASIMPORT-11** — As rotas não têm limiter por IP pré-autenticação; qual camada de borda
  (proxy/WAF) faria esse papel em produção é desconhecido.

## 14. Checklist de paridade

- [ ] `PC-NOTICIASIMPORT-15` — `GET /health` com `X-Access-Key`, `X-Internal-Key` ou Bearer válidos devolve o mesmo `200`. Fonte: `InternalApiAuth.php:124-141`.
- [ ] `PC-NOTICIASIMPORT-16` — `401 missing_key` sem header e `401 invalid_key` com segredo inventado. Fonte: `:42-56`; teste `NoticiasImportTransportTest.php:161-174`.
- [ ] `PC-NOTICIASIMPORT-17` — `consumer` divergente devolve `403 consumer_mismatch` sem efeito de domínio. Fonte: `NoticiasImportController.php:98-103`.
- [ ] `PC-NOTICIASIMPORT-18` — `dir=/etc` devolve `422 invalid_dir`. Fonte: `:86-91`; teste `:129-140`.
- [ ] `PC-NOTICIASIMPORT-19` — `abilities=['noticias:*']` autoriza `/run`; `abilities=['outro:*']` devolve `403 scope_not_allowed`. Fonte: `InternalApiKey.php:185-204`.
- [ ] `PC-NOTICIASIMPORT-20` — `allowed_ips` com um IP diferente devolve `403 ip_not_allowed`. Fonte: `:82-85,154-163`.
- [ ] `PC-NOTICIASIMPORT-21` — `--ttl=0` grava `expires_at=null`; `--ttl=90` grava ~90 dias. Caso negativo: `--ttl=abc` e `--ttl=-1` não podem criar chave perpétua/expirada — hoje criam (`(int)` → `0`/passado) e devem ser rejeitados com exit `1` na reimplementação (`SEC-G4-07`). Fonte: `InternalApiKey.php:105`; comando `:21,64`.
- [ ] `PC-NOTICIASIMPORT-22` — Reexecutar `apply` do mesmo lote retorna `skipped` para todos os itens e mantém a contagem de `v2_news`. Fonte: `NoticiasImportService.php:221-225,451-459`.
- [ ] `PC-NOTICIASIMPORT-23` — `422` de aborto traz `context.abort_code` e **não** traz `error`. Fonte: `NoticiasImportReport.php:50-55`.
- [ ] `PC-NOTICIASIMPORT-24` — Resposta `200` e `429` incluem `X-RateLimit-Limit` e `X-RateLimit-Remaining`. Fonte: `ThrottleApiToken.php:43-57`.
- [ ] `PC-NOTICIASIMPORT-25` — Chave `consumer=PE` com `dir` de um lote de `RJ` executa e usa `RJ` do payload; a correção recomendada deve recusar (403/422) e auditar o `dir` (`SEC-G4-05`). Fonte: `NoticiasImportController.php:84-111,124-141`.
- [ ] `PC-NOTICIASIMPORT-26` — 10 tentativas seguidas com chave inválida retornam `401` (nunca `429`), sem `X-RateLimit-*`, e geram 10 linhas `[InternalApiAuth]` (`SEC-G4-06`). Fonte: `InternalApiAuth.php:42-58,218-232`.
- [ ] `PC-NOTICIASIMPORT-27` — Recomendado (não existe hoje): limiter por IP antes da autenticação responde `429` sem consultar `v2_internal_api_keys`; o balde por chave continua valendo depois. Fonte: `Routes/api.php:12-18`; `ThrottleApiToken.php:24-49`.

## 15. Evidências consultadas

- `V2/Modules/Support/NoticiasImport/Routes/api.php:1-19`
- `V2/Modules/Support/NoticiasImport/Controllers/NoticiasImportController.php:50-141`
- `V2/Modules/Support/NoticiasImport/Middleware/InternalImportKey.php:1-47`
- `V2/Modules/Support/NoticiasImport/Models/InternalImportKey.php:1-23`
- `V2/Modules/Support/NoticiasImport/Console/Commands/NoticiasImportKeyCommand.php:1-157`
- `V2/Modules/Support/NoticiasImport/Migrations/2026_09_17_000002_create_v2_internal_api_keys_table.php:28-41`
- `V2/Modules/Support/NoticiasImport/Migrations/2026_09_18_000003_add_consumer_abilities_to_v2_internal_api_keys.php:30-38`
- `V2/Modules/Support/NoticiasImport/Views/cms/index.blade.php:66-143`
- `V2/Core/Security/InternalApiKey.php:70-241`
- `V2/Core/Middleware/InternalApiAuth.php:23-232`
- `V2/Core/Middleware/ThrottleApiToken.php:13-59`
- `V2/Core/Providers/V2ServiceProvider.php:290-313`
- `config/v2.php:24-44`
- `app/Http/Kernel.php:45-47,63-64`
- `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:27-115,129-164,175-250,451-468,489-559`
- `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportReport.php:50-116`
- `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportNormalizer.php:50-61`
- `V2/Modules/Publicacoes/Noticias/Models/NewsImportKey.php:16-58`
- `V2/Modules/Publicacoes/Noticias/Migrations/2026_09_17_000001_create_v2_news_import_keys_table.php:29-36`
- `V2/Modules/Conexoes/UnidadeRegional/Routes/api.php:1-38`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:446-474`
- `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:265-300`
- `tests/Feature/V2/Modules/Support/NoticiasImport/NoticiasImportTransportTest.php:18-194`
- `_qa/security-g1.md:96-107`
