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

# Support/NoticiasImport

> Transporte interno da importação de lotes de notícias. A escrita em `v2_news` pertence ao
> `NoticiasImportService` (unidade `MOD-PUBLICACOES-NOTICIAS`, Onda 4); este módulo é dono do
> **canal** (página de suporte + endpoint JSON), da **credencial** (`v2_internal_api_keys`) e do
> **relatório** devolvido. O contrato HTTP completo está em [`contrato-api.md`](contrato-api.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Permite que a equipe de suporte (sessão CMS) ou um coletor externo (chave persistente) execute a importação de um diretório de lote em dry-run ou apply, devolvendo o relatório por item. |
| Entrada | `GET/POST /cms/importacao-noticias[/run]` (sessão Sentinel + e-mail de suporte) e `GET /api/v2/noticiasimport/health`, `POST /api/v2/noticiasimport/run` (chave interna). Comando `v2:noticias-import-key`. |
| Saída | HTML `v2-noticiasimport::cms.index`; JSON do `NoticiasImportReport` (`context`, `summary`, `items`, `duration_ms`, `generated_at`); tabela `v2_internal_api_keys`; segredo exibido uma única vez no console. |
| Efeitos colaterais | `last_used_at` atualizado a cada requisição autenticada (`markUsed`); log estruturado `[InternalApiAuth]`; em `apply`, o serviço grava notícias/mídia/tags, registra `v2_news_import_keys`, registra `ActivityLogger` (`imported`) e limpa o page cache. |
| Particularidade central | Autenticação **máquina-a-máquina por chave persistente hasheada**, com escopo/abilities/IP/TTL/revogação/consumer; a checagem de `consumer` **não** é feita pelo middleware (ver §3.2, correção de `SEC-G1-05`). |
| Como se integra | Rotas sob `api/v2/noticiasimport` (prefixo automático) e `cms/importacao-noticias`; `Controller` injeta `NoticiasImportService`; alias local de `V2\Core\Security\InternalApiKey`; view no namespace `v2-noticiasimport`. |
| Se removido, quebra | `NoticiasImportarLote` (CLI) continua funcionando, mas o endpoint e a página de suporte somem; chaves já emitidas ficam órfãs (tabela permanece); o teste `NoticiasImportTransportTest` falha. |
| Superfície pública | `NoticiasImportController@index/@run/@apiRun/@apiHealth`, `InternalImportKey` (middleware), `V2\Modules\Support\NoticiasImport\Models\InternalImportKey`, `NoticiasImportKeyCommand`, `ModuleProvider::getConfig()`. |

## 1. Efeito

### 1.1 O que cada perfil consegue fazer

| Perfil | Ação | Resultado observável |
|---|---|---|
| E-mail de suporte (`config('v2.support.email')`) com papel Sentinel | abrir `/cms/importacao-noticias` | tela com diretório default, select de regionais ativas e de status; sem escrita |
| E-mail de suporte | clicar “Dry-run” | `POST /run` com `apply=0`; relatório renderizado; nenhuma notícia criada (`V2/Modules/Support/NoticiasImport/Views/cms/index.blade.php:138`) |
| E-mail de suporte | clicar “Aplicar de fato” e confirmar | `POST /run` com `apply=1`; notícias/mídia gravadas pelo serviço (`:139-141`) |
| Portador de chave interna válida | `GET /api/v2/noticiasimport/health` | `200 {"status":"ok","scope":"noticias:import"}` |
| Portador de chave interna válida | `POST /api/v2/noticiasimport/run` | relatório JSON; `422` se o contexto abortar (ex.: regional inexistente) |
| Qualquer outro usuário autenticado no CMS | qualquer rota do módulo | `403 Forbidden. Support area is restricted.` (JSON/AJAX) ou redirect ao dashboard com flash (`V2/Core/Middleware/V2PermissionMiddleware.php:50-67`) |
| Anônimo | rotas CMS | `302` para `v2.cms.login` (`CmsAuthenticate.php:21-26`) |
| Anônimo / chave inválida | rotas de API | `401` JSON com `error` estável (`InternalApiAuth.php:42-56`) |

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| Linha em `v2_internal_api_keys` com `key_hash` sha256, `key_prefix`, `scope`, `consumer`, `abilities`, `allowed_ips`, `expires_at` | `InternalApiKey::issue()` chamado pelo comando | `V2/Core/Security/InternalApiKey.php:94-111` |
| `last_used_at` atualizado a cada requisição autenticada (inclusive `/health`) | `InternalApiAuth::authorize()` → `markUsed()` | `V2/Core/Middleware/InternalApiAuth.php:92`; `InternalApiKey.php:226-230` |
| Log `[InternalApiAuth] <resultado>` com `key_prefix`, `key_id`, `consumer`, IP, método, path, `ts` | `InternalApiAuth::audit()` | `InternalApiAuth.php:218-232` |
| `revoked_at` preenchido (sem apagar a linha) | `revoke()` via `--revoke` | `InternalApiKey.php:237-241`; `Console/Commands/NoticiasImportKeyCommand.php:92-105` |
| Em `apply=1`: notícias, mídia, tags e chave de idempotência | `NoticiasImportService::applyItem()` | `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:219-250` |
| Em `apply=1` com pelo menos um `created`: `v2:clear-page-cache` | `clearPageCache()` | `NoticiasImportService.php:489-500` |
| Em `apply=1`: entrada de auditoria `imported` | `ActivityLoggerService::log()` | `NoticiasImportService.php:244-249` |
| Em qualquer execução: log `[NoticiasImport] Execução concluída` | `logRun()` | `NoticiasImportService.php:543-559` |
| Nenhuma escrita quando `apply` é falso/inválido | `processItem()` devolve `planned` | `NoticiasImportService.php:154-164` |

### 1.3 Contrato observável (entrada → saída)

| Entrada | Saída |
|---|---|
| CMS `GET /cms/importacao-noticias` como suporte | `200` HTML com `$defaultDir`, `$regionais`, `$statuses` |
| CMS `POST /run` `{dir: válido, regional: "PE", status: "draft", apply: 0}` | `200` JSON do relatório; `summary.planned = total` |
| CMS `POST /run` sem `dir` ou com caminho fora das raízes | `422 {"error":"invalid_dir","message":"Diretório inválido ou fora da área permitida (storage/app/imports ou TMP)."}` |
| API `POST /run` com `regional` diferente do `consumer` da chave | `403 {"error":"consumer_mismatch","message":"Esta chave não autoriza o consumidor/regional informado."}` |
| API `POST /run` com `regional` inexistente no banco | `422` com `context.aborted=true`, `abort_code="regional_not_found"`, `abort_message="Unidade regional não encontrada: <sigla>"` |
| API sem header/chave | `401 {"error":"missing_key","message":"Chave interna não fornecida."}` |
| API com segredo não encontrado | `401 {"error":"invalid_key","message":"Chave interna inválida."}` |
| API com chave revogada | `401 {"error":"key_revoked","message":"Chave interna revogada."}` |
| API com chave expirada | `401 {"error":"key_expired","message":"Chave interna expirada."}` |
| API com IP fora da allow-list | `403 {"error":"ip_not_allowed","message":"IP não autorizado para esta chave."}` |
| API com escopo/ability insuficiente | `403 {"error":"scope_not_allowed","message":"Escopo insuficiente para esta operação."}` |
| 31ª chamada em 1 minuto a `/run` | `429 {"error":"too_many_requests","message":"Limite de requisições excedido. Tente novamente em <n> segundos.","retry_after":<n>}` |

### 1.4 O que este módulo NÃO faz

- **Não escreve em `v2_news`**: toda escrita é delegada a `NoticiasImportService` (`Controllers/NoticiasImportController.php:105-111`); o módulo não tem `Models` de domínio de notícias.
- **Não valida o payload com FormRequest**: lê `input()` direto, sem `$this->validate()`; só `dir` é validado (confinamento de caminho) e o `status` é silenciosamente normalizado pelo serviço.
- **Não lista lotes disponíveis**: o operador digita o caminho absoluto/relativo em texto livre.
- **Não cria nem exibe chaves no CMS**: emissão/revogação/listagem só por comando (`NoticiasImportKeyCommand.php:34-42`).
- **Não envia a chave por e-mail**, não a recupera depois (só `key_hash`), não armazena o segredo em log.
- **Não impõe unicidade por `consumer`**: “uma chave por regional” é convenção, não constraint (`Migrations/2026_09_18_000003_add_consumer_abilities_to_v2_internal_api_keys.php:31-37`).
- **Não tem UI de portal** nem view pública.
- **Não é a API das Unidades Regionais**: ver §3.1 e `contrato-api.md` §3.5.

## 2. Abordagem

### 2.1 Estratégia em uma página

```mermaid
flowchart LR
  subgraph CMS
    A[GET /cms/importacao-noticias] --> B[web + module.active:NoticiasImport]
    B --> C[CmsAuthenticate → V2PermissionMiddleware]
    C -->|e-mail != v2.support.email| X[403 / redirect dashboard]
    C -->|e-mail = suporte| D[Controller::index → view]
    E[POST /cms/importacao-noticias/run + CSRF] --> B
  end
  subgraph API
    F[X-Access-Key / X-Internal-Key / Bearer] --> G[api group]
    G --> H[InternalImportKey (prefix gate)]
    H --> I[InternalApiAuth: exists, revogada, expirada, IP, ability]
    I --> J[throttle.api:30,1]
  end
  D --> K[Controller::execute]
  E --> K
  J --> K
  K --> L[resolveDir: realpath + raízes permitidas]
  L --> M[consumerAllows regional]
  M --> N[NoticiasImportService::run]
  N --> O[(v2_news, v2_news_import_keys, Gallery, ActivityLogger)]
  N --> P[relatório JSON]
```

O desenho separa **credencial** (Core, reutilizável) de **política** (este módulo): o middleware de
Core valida a chave; o controller aplica o recorte de consumidor e de diretório; o serviço de
Notícias aplica o domínio.

### 2.2 Mecânica passo a passo

1. **Boot** — `V2ServiceProvider` encontra `V2/Modules/Support/NoticiasImport`, instancia
   `ModuleProvider`, injeta `group = Support`, carrega migrations (`V2ServiceProvider.php:115-118`),
   registra views em `v2-noticiasimport` (`:120-123`) e mapeia rotas (`:126-127`).
2. **Rotas** — `Routes/cms.php` recebe do provider `web` + `module.active:NoticiasImport` e declara
   `cms/importacao-noticias` + `CmsAuthenticate` (`V2ServiceProvider.php:303-307`;
   `Routes/cms.php:10-16`). `Routes/api.php` recebe do provider o prefixo `api/v2/noticiasimport` e
   o grupo `api` (`V2ServiceProvider.php:309-313`).
3. **Gate CMS** — `CmsAuthenticate` exige Sentinel e ao menos um papel (`CmsAuthenticate.php:27-44`);
   em seguida `V2PermissionMiddleware` casa `active_pattern` `['cms/importacao-noticias*']`
   (`ModuleProvider.php:26`) e bloqueia todo e-mail diferente de `config('v2.support.email')`
   (`V2PermissionMiddleware.php:50-67`).
4. **Gate API** — `InternalImportKey::handle()` só age sob `api/v2/noticiasimport[/...]`
   (`Middleware/InternalImportKey.php:31-33`) e delega a `InternalApiAuth` com escopo
   `config('v2.internal_import_keys.scope_noticias', 'noticias:import')` (`:43-46`).
5. **Autenticação da chave** — `InternalApiAuth::handle()` extrai o segredo de
   `v2.internal_api.header` (default `X-Access-Key`), `X-Internal-Key` ou Bearer
   (`InternalApiAuth.php:124-141`); se for a `ACCESS_KEY` estática, autoriza sem banco (`:47-49`);
   senão busca por `hash('sha256', secret)` (`InternalApiKey.php:119-122`).
6. **Ciclo de vida** — ordem exata: revogada → expirada → IP → ability
   (`InternalApiAuth.php:72-90`); em sucesso grava `last_used_at`, audita e publica
   `internal_api_key` / `internal_import_key` nos atributos da request (`:92-94,184-191`).
7. **Throttle** — `throttle.api:30,1` roda **depois** da autenticação (middleware de rota anexado ao
   grupo) e usa `substr(bearerToken,0,12)` como chave; sem Bearer, cai em `api_token:ip:<ip>`
   (`ThrottleApiToken.php:29-34,36-49`). Consequência: **401/403 não passam pelo limiter** — o
   middleware de chave retorna antes do `$next` (`InternalApiAuth.php:42-58,82-96`), então falhas não
   consomem cota e cada uma grava uma linha de auditoria (`:218-232`). `GET /health` não tem throttle.
8. **Execução** — `execute()` resolve o diretório (`resolveDir`), resolve `regional` (input →
   `consumer` da chave → `'PE'`), aplica `consumerAllows()` e chama
   `NoticiasImportService::run($dir, options)` com `author_id`/`author_email` do Sentinel
   (`Controllers/NoticiasImportController.php:82-111`).
9. **Relatório** — o serviço normaliza opções (status inválido ou `pending_approval` → `draft`),
   aborta se a regional não existir, processa item a item e devolve
   `NoticiasImportReport::toArray()` (`NoticiasImportService.php:86-115`;
   `NoticiasImportReport.php:97-116`).
10. **Resposta** — `200` se `context.aborted` ausente; `422` se abortado
    (`Controllers/NoticiasImportController.php:113-115`).

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

- **Credencial no Core, política no módulo** `[C]`: `InternalApiAuth` e `InternalApiKey` são
  genéricos e compartilhados (`InternalApiAuth.php:10-22`); o módulo só fixa escopo e prefixo via
  subclasse (5 linhas efetivas). Isso evita duplicar validação e permite reuso por outra API interna.
- **Alias de model em vez de mover o namespace** `[C]`: `InternalImportKey extends InternalApiKey`
  mantém compatibilidade de testes/comandos (`Models/InternalImportKey.php:9-23`).
- **Hash em vez de segredo** `[C]`: `key_hash = sha256(secret)` e `key_prefix` de 12 caracteres
  para exibição/rate-limit (`InternalApiKey.php:103-104`); não há recuperação do segredo.
- **Fail-closed** `[C]`: qualquer falha de credencial retorna 401/403; o único caminho sem registro
  é a `ACCESS_KEY` estática, explicitamente rotulada bootstrap/break-glass (`InternalApiAuth.php:99-116`).
- **Dry-run como default** `[C]`: `apply` default `false` no controller e no serviço
  (`Controllers/NoticiasImportController.php:108`; `NoticiasImportService.php:135`).
- **Confinamento por `realpath` + prefixo** `[C]`: impede `../`, mas não é um sandbox de sistema de
  arquivos e **não é escopado pelo consumidor** (ver §3.3 e §3.6): a checagem só olha as raízes, nunca
  a chave. A premissa implícita é que todo lote sob as raízes pertence à mesma operação.
- **Throttle depois da autenticação** `[C]`: o limiter está na rota (`Routes/api.php:16-18`), então só
  requisições já autorizadas o alcançam; 401/403 são ilimitados e auditados por tentativa
  (`InternalApiAuth.php:42-58,82-96,218-232`). O trade-off evita gastar cota com chave inválida, mas
  deixa brute-force/log-flood sem limite; a recomendação é um limiter **de IP antes** da autenticação,
  mantendo o balde por chave depois dela.
- **Consumer como recorte de alvo, não de escopo** `[C]`: `consumer` restringe a sigla regional do
  payload (`InternalApiKey.php:212-219`), enquanto `scope`/`abilities` restringem a operação.

### 2.4 Trechos de código pivô

```php
// V2/Modules/Support/NoticiasImport/Middleware/InternalImportKey.php:29-36 — o wrapper só
// autentica o próprio prefixo; o resto da aplicação segue sem chave.
public function handle($request, Closure $next, $scope = null)
{
    if (!$request->is(self::ROUTE_PREFIX, self::ROUTE_PREFIX . '/*')) {
        return $next($request);
    }

    return parent::handle($request, $next, $scope ?: $this->defaultScope());
}
```

O middleware é registrado no grupo da API de importação; o gate por prefixo torna o wrapper seguro
mesmo se for reutilizado em um grupo maior. `ROUTE_PREFIX` é `api/v2/noticiasimport`
(`InternalImportKey.php:18`), exatamente o prefixo que o provider aplica a `Routes/api.php`
(`V2ServiceProvider.php:310`).

```php
// V2/Core/Middleware/InternalApiAuth.php:42-58 — fail-closed e atalho estático.
if ($secret === null) {
    $this->audit('warning', null, $request, 'missing_key');
    return $this->deny(401, 'missing_key', 'Chave interna não fornecida.');
}

if ($this->isStaticKey($secret)) {
    return $this->authorizeStatic($request, $next, $scope);
}

$key = InternalApiKey::findBySecret($secret);

if (!$key) {
    $this->audit('warning', null, $request, 'invalid_key');
    return $this->deny(401, 'invalid_key', 'Chave interna inválida.');
}

return $this->authorize($request, $next, $key, $scope);
```

A `ACCESS_KEY` estática é comparada com `hash_equals` e **nunca** é logada
(`InternalApiAuth.php:149-154,218-232`); ela pula todo o ciclo de vida e não tem consumidor — por
isso o controller cai no default `'PE'`. Este é o ponto exato do achado `SEC-G1-05`.

```php
// V2/Core/Security/InternalApiKey.php:94-111 — emissão: hash, prefixo, TTL e allow-list.
public static function issue(array $data): array
{
    $secret = bin2hex(random_bytes(32));

    $key = new static();
    $key->name = (string) $data['name'];
    $key->consumer = !empty($data['consumer']) ? strtoupper((string) $data['consumer']) : null;
    $key->scope = (string) $data['scope'];
    $key->abilities = !empty($data['abilities']) ? array_values((array) $data['abilities']) : null;
    $key->key_hash = hash('sha256', $secret);
    $key->key_prefix = substr($secret, 0, 12);
    $key->expires_at = !empty($data['ttl_days']) ? now()->addDays((int) $data['ttl_days']) : null;
    $key->allowed_ips = !empty($data['allowed_ips']) ? array_values((array) $data['allowed_ips']) : null;
    $key->created_by = isset($data['created_by']) ? $data['created_by'] : null;
    $key->save();

    return ['key' => $key, 'secret' => $secret];
}
```

`consumer` é normalizado para maiúsculas na emissão, o que fecha a comparação com
`strtoupper($regional)` no controller. `ttl_days = 0` produz `expires_at = null` (sem expiração).

```php
// V2/Modules/Support/NoticiasImport/Controllers/NoticiasImportController.php:93-111 — consumidor no controller + delegação.
/** @var \V2\Core\Security\InternalApiKey|null $key */
$key = $request->attributes->get('internal_api_key');
$defaultRegional = $key && $key->consumer ? $key->consumer : 'PE';
$regional = strtoupper((string) $request->input('regional', $defaultRegional));

if ($key && !$key->consumerAllows($regional)) {
    return response()->json([
        'error'   => 'consumer_mismatch',
        'message' => 'Esta chave não autoriza o consumidor/regional informado.',
    ], 403);
}

$report = $service->run($dir, [
    'regional'     => $regional,
    'status'       => (string) $request->input('status', News::STATUS_DRAFT),
    'apply'        => filter_var($request->input('apply', false), FILTER_VALIDATE_BOOLEAN),
    'author_id'    => optional(Sentinel::getUser())->id,
    'author_email' => optional(Sentinel::getUser())->email,
]);
```

`consumerAllows()` é chamado **aqui** (`:98-103`), não pelo middleware. Com a `ACCESS_KEY` estática,
`bind()` grava `internal_api_key = null` (`InternalApiAuth.php:110-116`), a condição `$key && ...`
é falsa e a sigla cai no default `'PE'` (`:94-95`): a chave estática ignora o controle de consumidor.

```php
// V2/Modules/Support/NoticiasImport/Controllers/NoticiasImportController.php:124-141 —
// confinamento do diretório por realpath + prefixo de raiz.
private function resolveDir(string $dir)
{
    $real = realpath($dir);

    if ($real === false || !is_dir($real)) {
        return null;
    }

    foreach ([storage_path('app/imports'), base_path('TMP')] as $allowed) {
        $base = realpath($allowed);

        if ($base !== false && strpos($real, $base) === 0) {
            return $real;
        }
    }

    return null;
}
```

Raízes são **hardcoded** e precisam existir: `realpath()` de uma raiz inexistente devolve `false` e
a raiz é ignorada. Não há `config()` para isso. O teste de prefixo é textual (ver §3.3).

```php
// V2/Modules/Support/NoticiasImport/Console/Commands/NoticiasImportKeyCommand.php:52-67 —
// emissão: nome obrigatório, consumer, escopo configurável, abilities e IPs em CSV.
$name = trim((string) $this->option('name'));

if ($name === '') {
    $this->error('Informe --name=<descrição da chave>.');
    return 1;
}

$result = InternalImportKey::issue([
    'name'        => $name,
    'consumer'    => (string) $this->option('consumer'),
    'scope'       => config('v2.internal_import_keys.scope_noticias', InternalImportKey::SCOPE_NOTICIAS_IMPORT),
    'abilities'   => $this->parseCsv((string) $this->option('abilities')),
    'ttl_days'    => (int) $this->option('ttl'),
    'allowed_ips' => $this->parseCsv((string) $this->option('ips')),
    'created_by'  => config('v2.support.email'),
]);
```

O comando também é o único lugar que escreve `created_by`. `--ttl` tem default **90** no signature
(`:21`), ignorando `config('v2.internal_import_keys.ttl_days')`; `--ips` usa `parseCsv`, e o método
`parseIps()` (`:154-157`) é código morto.

```javascript
// V2/Modules/Support/NoticiasImport/Views/cms/index.blade.php:106-123 — o payload do CMS.
function send(apply) {
    var payload = {
        dir: document.getElementById('import-dir').value,
        regional: document.getElementById('import-regional').value,
        status: document.getElementById('import-status').value,
        apply: apply ? 1 : 0
    };

    feedback(apply ? 'Aplicando…' : 'Executando dry-run…', 'info');

    fetch(endpoint, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'X-CSRF-TOKEN': token
        },
        body: JSON.stringify(payload)
    // ... (fecha o fetch e trata a resposta em .then)
```

`apply` viaja como `1`/`0`; o controller converte com `FILTER_VALIDATE_BOOLEAN`. A rota CMS exige
CSRF (`web` + `VerifyCsrfToken`), obtido do `<meta name="csrf-token">` (`:70-71`). `status` sai do
select com apenas `draft`/`published` (`:31-34,42-46`).

### 2.5 Algoritmos, consultas e custos

- **Busca da chave**: `SELECT * FROM v2_internal_api_keys WHERE key_hash = ? LIMIT 1`
  (`InternalApiKey.php:119-122`), usando o índice único `key_hash`. Sem N+1.
- **`markUsed()`** faz um `UPDATE` por requisição autenticada (inclusive `/health`), tocando
  `updated_at` apesar do comentário dizer o contrário (`InternalApiKey.php:222-230`).
- **`issue()`** faz um `INSERT`; `revoke()` um `UPDATE`; `--list` carrega **todas** as chaves sem
  paginação (`NoticiasImportKeyCommand.php:112-125`) — custo cresce com o histórico, e soft-deleted
  ficam de fora por `SoftDeletes` (`InternalApiKey.php:34`).
- **`index()`** consulta `UnidadeRegional::active()->orderBy('acronym')` (usa `is_active`); cardinalidade
  baixa (dezenas) (`Controllers/NoticiasImportController.php:30`).
- **Custo real da execução** está no serviço: uma transação por item em `apply`
  (`NoticiasImportService.php:227-239`), dedupe de mídia por sha256 em memória
  (`:344-355`) e `hash_file` de cada imagem.
- **Throttle** é por prefixo de Bearer ou IP, não por `key_id`: duas chaves usadas como `X-Access-Key`
  no mesmo IP compartilham o mesmo balde (`ThrottleApiToken.php:29-34`).

## 3. Particularidades

### 3.1 O que só existe aqui

- **Único consumidor de `InternalApiAuth` no código atual** `[C]`: o alias `internal.api` está
  registrado no Kernel (`app/Http/Kernel.php:64`), mas nenhuma outra rota o usa; a API das unidades
  regionais autentica **dentro do controller** por Bearer (`UnidadeRegionalNewsApiController.php:446-474`).
- **Única tabela de credencial genérica** (`v2_internal_api_keys`) do V2, criada por este módulo, mas
  deliberadamente sem FK para usuário ou unidade regional.
- **Única rota de API que aceita três formas de credencial** ao mesmo tempo (`X-Access-Key`,
  `X-Internal-Key` legado, `Authorization: Bearer`).
- **Alias de model com constante duplicada**: `InternalImportKey::SCOPE_NOTICIAS_IMPORT`
  (`Models/InternalImportKey.php:22`) repete `InternalApiKey::SCOPE_NOTICIAS_IMPORT`
  (`InternalApiKey.php:70`).
- **Página de suporte que executa importação de produção** com confirmação `window.confirm`
  (`Views/cms/index.blade.php:139-141`), sem segundo fator nem trilha de auditoria própria.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-NOTICIASIMPORT-01 | O grupo Support só é acessível ao e-mail `config('v2.support.email')`; `super-admin` **não** é exceção porque o bloqueio de grupo vem antes do bypass. | `403`/redirect para qualquer outro e-mail. | `V2PermissionMiddleware.php:50-72` |
| RN-NOTICIASIMPORT-02 | O middleware de Core **não valida `consumer`**; quem valida é o controller. | Correção de `SEC-G1-05`; só `POST /run` (não `/health`) aplica o recorte. | `InternalApiAuth.php:70-97`; `Controllers/NoticiasImportController.php:98-103` |
| RN-NOTICIASIMPORT-03 | Chave estática (`ACCESS_KEY`) é vinculada como `null`. | `key=null` → `defaultRegional='PE'`; `consumerAllows` não roda. | `InternalApiAuth.php:110-116`; `Controllers/NoticiasImportController.php:94-95` |
| RN-NOTICIASIMPORT-04 | Ordem de negação: revogada → expirada → IP → ability. | Uma chave revogada responde `key_revoked` mesmo com IP errado. | `InternalApiAuth.php:72-90` |
| RN-NOTICIASIMPORT-05 | `scope` exato concede sempre; `abilities` aceitam `*`, valor exato e prefixo `dominio:*`. | `noticias:*` autoriza `noticias:import`. | `InternalApiKey.php:185-204` |
| RN-NOTICIASIMPORT-06 | `allowed_ips` vazio/`null` = qualquer IP; comparação estrita de string. | CIDR não é suportado; IPv6 literal precisa bater exatamente. | `InternalApiKey.php:154-163` |
| RN-NOTICIASIMPORT-07 | `expires_at = null` = sem expiração; `--ttl=0` produz isso. | Chave perpétua até revogação. | `InternalApiKey.php:105`; `NoticiasImportKeyCommand.php:21` |
| RN-NOTICIASIMPORT-08 | `realpath($dir)` precisa ser diretório e começar textualmente por `storage/app/imports` ou `TMP`. | `422 invalid_dir` fora dessas raízes. | `Controllers/NoticiasImportController.php:124-141` |
| RN-NOTICIASIMPORT-09 | A validação de `dir` acontece **antes** da de consumidor. | `dir` inválido responde 422 mesmo para chave com `consumer` divergente. | `Controllers/NoticiasImportController.php:84-103` |
| RN-NOTICIASIMPORT-10 | `regional` do payload vence o `consumer` da chave na resolução; o consumer só autoriza/nega. | `regional` vazio ou divergente → 403; ausente → default. | `Controllers/NoticiasImportController.php:95-103` |
| RN-NOTICIASIMPORT-11 | `status` desconhecido ou `pending_approval` é rebaixado a `draft` sem erro. | Payload não falha por status inválido. | `NoticiasImportService.php:129-137` |
| RN-NOTICIASIMPORT-12 | `apply` só é verdadeiro para `1/true/on/yes`; valor inválido vira dry-run. | `apply=banana` não grava. | `Controllers/NoticiasImportController.php:108` |
| RN-NOTICIASIMPORT-13 | Relatório com `context.aborted` responde `422`; sem aborto, `200`. | Falha de regional é 422, não 404. | `Controllers/NoticiasImportController.php:113-115` |
| RN-NOTICIASIMPORT-14 | `health` devolve o escopo configurado, não o escopo efetivo da chave. | Chave com `abilities=['*']` vê `"scope":"noticias:import"`. | `Controllers/NoticiasImportController.php:67-73` |
| RN-NOTICIASIMPORT-15 | Segredo nunca é logado; auditoria usa `key_prefix`/`key_id`. | Log não permite reconstruir a chave. | `InternalApiAuth.php:218-232` |
| RN-NOTICIASIMPORT-16 | Revogar não apaga: `revoked_at` é preenchido. | `--list` continua exibindo a chave com data de revogação. | `InternalApiKey.php:237-241`; `NoticiasImportKeyCommand.php:112-133` |
| RN-NOTICIASIMPORT-17 | `consumer` não restringe `dir`: qualquer caminho sob `storage/app/imports`/`TMP` vale para qualquer chave. | Chave `PE` lê/aplica o lote de `RJ`; o relatório expõe `title`, `source_url`, `cover`, `body_images`, `tags`. | `Controllers/NoticiasImportController.php:84-111,124-141`; `NoticiasImportReport.php:97-116` |
| RN-NOTICIASIMPORT-18 | 401/403 retornam antes do `throttle.api` e cada falha grava auditoria. | Falhas não consomem cota; tentativas repetidas nunca viram 429. | `InternalApiAuth.php:42-58,82-96,218-232`; `Routes/api.php:16-18` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **`dir` ausente**: `realpath('')` não resulta em diretório sob as raízes `[I]` (devolve `false` ou o
  cwd do processo, tipicamente `public/` em HTTP) → `422 invalid_dir`.
- **Caminho relativo**: o `defaultDir` da view é
  `storage/app/imports/sucesu_pe_noticias` (`Controllers/NoticiasImportController.php:29`), mas
  `realpath` resolve relativo ao **cwd do processo PHP**, não ao `base_path()` `[I]`. Em requisição
  HTTP, o caminho default tende a falhar; em CLI, funciona se o cwd for a raiz do projeto.
- **Raízes inexistentes**: neste checkout, `storage/app/imports` e `TMP` não existem; enquanto não
  existirem, `resolveDir` sempre devolve `null` `[C]` (verificado por `realpath` das raízes no laço).
- **Colisão de prefixo**: `strpos($real, $base) === 0` aceita `storage/app/imports-backup` como se
  fosse `storage/app/imports/...` `[I]` — é comparação textual sem separador.
- **Symlink**: `realpath` resolve o alvo antes da comparação; link dentro da raiz apontando para fora
  é rejeitado `[I]`.
- **`regional` vazio explícito** (`"regional": ""`): `input()` não usa o default; `''` passa pelo
  `consumerAllows` (403 se houver consumer) ou chega ao serviço e aborta com
  `regional_not_found` (422).
- **`consumer` minúsculo** gravado fora do `issue()` (SQL manual): a comparação é `hash_equals`
  case-sensitive contra o valor em maiúsculas (`InternalApiKey.php:218`), então a chave nunca autoriza.
- **Chave expirada e revogada ao mesmo tempo**: prevalece `key_revoked` (revogação checada antes).
- **`allowed_ips` com IP do proxy**: `$request->ip()` é influenciado por `TrustProxies` (`'*'`), então
  a allow-list é defesa secundária `[I]`.
- **`/health` sem throttle**: um portador válido pode chamar sem limite; cada chamada gera `UPDATE`
  e log.
- **`--list` com soft-deleted**: não aparecem (o model usa `SoftDeletes`), mas `--revoke=<uuid>` de
  uma chave apagada retorna “Chave não encontrada” porque `find()` respeita o escopo.
- **`--ips` com espaços**: `parseCsv` faz `trim` e remove vazios (`NoticiasImportKeyCommand.php:141-146`).
- **Chave de uma regional executando lote de outra**: `dir` e `consumer` são independentes (ver §3.6);
  a chave só autoriza a sigla do payload, nunca o caminho.
- **Falhas de chave não são throttled**: 1.000 tentativas inválidas geram 1.000 respostas 401/403 e
  1.000 linhas `[InternalApiAuth]`, sem 429 — o `throttle.api` só vê requisições autorizadas.
- **`--ttl` não inteiro**: `(int) $this->option('ttl')` (`NoticiasImportKeyCommand.php:64`) converte
  `abc`, `1.5` ou `-1`; lixo vira `0` → **chave perpétua** (`InternalApiKey.php:105`) e `-1` →
  `expires_at` no passado (chave nasce expirada). Só `--ttl=0` deveria ser perpétuo.

### 3.4 Armadilhas de reimplementação

1. **Mover `consumerAllows()` para o middleware** “para centralizar”: muda o contrato de `/health`
   (hoje não valida consumidor) e quebra a expectativa de chave estática `'PE'`; o middleware não
   conhece o payload.
2. **Assumir que `consumer` é validado no Core**: o Core é reutilizável e não tem noção de regional;
   sem o controller, `consumer` vira decoração.
3. **Tratar `consumer` como escopo**: escopo é `noticias:import`; `consumer` é alvo (`PE`, `RJ`).
   Confundir os dois faz a chave `noticias:*` ser interpretada como regional.
4. **Usar `prefix` de API no arquivo de rotas**: o provider já aplica `api/v2/noticiasimport`
   (`V2ServiceProvider.php:310`); duplicar gera `api/v2/noticiasimport/api/v2/noticiasimport`.
5. **Contar com `active_pattern` como string com `|`**: `Request::is()` recebe array; o padrão aqui é
   array de um item (`ModuleProvider.php:26`), que funciona. Seguir o padrão de `SupportConfigs`
   (string com `|`) faz o módulo **não casar** e o grupo Support não bloquear (`SEC-G1-04`).
6. **Esperar que `apply` aceite qualquer truthy**: `FILTER_VALIDATE_BOOLEAN` só aceita
   `1/true/on/yes`; `apply=2` é dry-run silencioso.
7. **Manter o throttle apenas depois da autenticação**: a ordem atual não gasta cota com chave
   inválida, mas deixa 401/403 ilimitados e com uma linha de auditoria por tentativa
   (brute-force/log-flood). Para produção, somar um limiter **por IP antes** da autenticação e
   preservar o balde por chave depois dela; apenas inverter a ordem transfere o problema para o balde
   alheio.
8. **Reutilizar esta chave para a API das regionais**: propósitos e escopos diferentes (§4.5).

### 3.5 Divergências documentação × código e dívidas

| Documento | Afirma | Código |
|---|---|---|
| `MODULE.md:52` | ordem “expirada → revogada” | código checa revogada primeiro (`InternalApiAuth.php:72-80`) |
| `MODULE.md:11-22` | “não unificar” com a API das regionais | correto; a API regional autentica por Bearer dentro do controller (`UnidadeRegionalNewsApiController.php:446-474`) |
| `MODULE.md:85` | “Idempotência por `sha256("regional:<sigla>:<url>")`” | o código monta `hash('sha256', 'regional:' . strtolower($sigla) . ':' . $url)` — a sigla é **sempre minúscula**, detalhe que o texto omite; a gravação ocorre só em `apply` (`NoticiasImportService.php:205-208,451-459`) |
| `MODULE.md:84` | “Uma chave por regional” | não há unique em `consumer`; é convenção (`2026_09_18_000003...:31-37`) |
| `MODULE.md:94` | comando `v2:noticias-importar-lote` como deste módulo | pertence a `Publicacoes/Noticias` (`app/Console/Kernel.php:21`) |
| `MODULE.md:39` | alias de rota `internal.api[:escopo]` | alias existe (`app/Http/Kernel.php:64`), mas este módulo usa a subclasse `InternalImportKey`; nenhuma rota usa o alias |
| `InternalApiKey.php:222` | “sem tocar em `updated_at`” | `save()` atualiza `updated_at` (`:226-230`) |
| `config/v2.php:42-43` | `internal_import_keys.ttl_days` e `max_attempts` | sem uso no código (o comando fixa 90; o throttle fixa 30) |
| código | `parseIps()` | método morto (`NoticiasImportKeyCommand.php:154-157`) |
| `Routes/api.php:9-10` | “Bearer + escopo” | correto, mas o throttle só usa Bearer se o cliente o enviar; com `X-Access-Key`, o balde é por IP (`ThrottleApiToken.php:29-34`) |
| `Views/cms/index.blade.php:81-83` | resumo lê `summary.total/created/planned/skipped/rejected/failed` | presentes em `NoticiasImportReport.php:103-111`; `summary.counts` existe e não é usado |

Dívidas adicionais: a tela não exibe `abort_message` (o JS mostra `body.message`, que não existe no
relatório abortado — cai em `body.error`, também ausente, e mostra “Falha na execução”)
(`Views/cms/index.blade.php:127-129`); não há paginação em `--list`; o relatório completo não é salvo
no CMS (o comando de Notícias grava em `storage/logs`, esta via não). Duas dívidas de segurança
fechadas em `SEC-G4-05`/`SEC-G4-07`: (a) `dir` não é escopado pelo consumidor e o caminho resolvido
não entra na auditoria (§3.6); (b) `--ttl` usa cast `(int)` sem validar inteiro `>= 0`, então lixo
vira `0` (perpétuo) e negativo cria chave expirada (§3.3).

### 3.6 Confinamento do lote × consumidor (SEC-G4-05)

`execute()` resolve e valida `dir` **antes** de ler a chave e o consumidor
(`Controllers/NoticiasImportController.php:84-103`), e `resolveDir()` só compara o caminho real com
`storage/app/imports` e `TMP` (`:124-141`). Não existe vínculo entre o diretório escolhido e o
`consumer` da chave: uma chave emitida para `PE` pode apontar para o lote de `RJ` (ou de qualquer
outra regional) e o serviço gravará as notícias na regional pedida no payload — ou, em dry-run,
apenas relatar o conteúdo. Como o relatório devolve por item `name`, `title`, `source_url`, `cover`,
`body_images` e `tags` (`NoticiasImportReport.php:97-116`), o canal permite **leitura e escrita de
lotes de outra regional** com uma única chave válida.

Postura recomendada (não implementada hoje): (a) derivar o diretório do consumidor —
`storage/app/imports/<consumer>/...` — e recusar `dir` fora dele; ou (b) allow-list de diretórios por
chave (coluna/JSON adicional) validada antes de chamar o serviço; e, em ambos os casos, (c) registrar
o `dir` resolvido no log de auditoria da execução — hoje a auditoria cobre `path`/`ip`/`key_prefix`
(`InternalApiAuth.php:218-232`) e o diretório só aparece no relatório de resposta.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `NoticiasImportController@index` | action CMS | `index(): View` | suporte autenticado + `module.active` | `v2.noticiasimport.cms.index` |
| `NoticiasImportController@run` | action CMS | `run(Request, NoticiasImportService): JsonResponse` | suporte + CSRF | `v2.noticiasimport.cms.run` |
| `NoticiasImportController@apiRun` | action API | `apiRun(Request, NoticiasImportService): JsonResponse` | chave com `noticias:import` | `v2.noticiasimport.api.run` |
| `NoticiasImportController@apiHealth` | action API | `apiHealth(): JsonResponse` | chave com `noticias:import` | `v2.noticiasimport.api.health` |
| `InternalImportKey` | middleware | `handle($request, Closure $next, $scope = null)` | rota sob `api/v2/noticiasimport` | `Routes/api.php:12` |
| `V2\Modules\Support\NoticiasImport\Models\InternalImportKey` | model alias | `extends V2\Core\Security\InternalApiKey`; `SCOPE_NOTICIAS_IMPORT` | tabela `v2_internal_api_keys` | comando, testes |
| `NoticiasImportKeyCommand` | comando | `v2:noticias-import-key {--name=} {--consumer=} {--abilities=} {--ttl=90} {--ips=} {--revoke=} {--list}` | — | operação/suporte |
| `ModuleProvider::getConfig` | provider | `(): array` | — | `V2ServiceProvider`, `SidebarComposer` |

### 4.2 Contrato de UI/JS

| Item | Contrato | Fonte |
|---|---|---|
| Layout | `v2-cms::layouts.app`, `title = "Importação de Notícias"` | `Views/cms/index.blade.php:1` |
| Breadcrumb | Dashboard → Suporte → Importação de Notícias | `:3-9` |
| Campos/DOM | `#import-dir` (texto), `#import-regional` (select de `acronym`), `#import-status` (select `draft`/`published`) | `:25-46` |
| Ações | `#btn-dry-run`, `#btn-apply` | `:51-56` |
| Feedback | `#import-feedback` (`role="status"`, `aria-live="polite"`) | `:61` |
| Relatório | `#import-report` (tabela injetada por `innerHTML`) | `:62,99-103` |
| Endpoint JS | `route('v2.noticiasimport.cms.run')` + `X-CSRF-TOKEN` | `:69-71,116-123` |
| Eventos | `click` nos dois botões; `confirm` antes de `apply` | `:138-141` |
| Mensagens | `'Executando dry-run…'`, `'Aplicando…'`, `'Falha na execução.'`, `'Erro de comunicação com o servidor.'`, `'Aplicar a importação de fato? Isto grava notícias.'` | `:114,128,134,140` |
| A11y | `<label for>` em todos os campos, `aria-describedby="import-dir-help"`, `aria-live`, `caption` visualmente oculta | `:25-28,61,101` |
| Sem JS | nada acontece; os botões são `type="button"` e não há `<form>` | `:50-57` |

### 4.3 Ordem e ciclo de vida

1. O provider precisa rodar **antes** de qualquer resolução de rota; é o boot padrão do V2
   (`V2ServiceProvider.php:92-133`).
2. As rotas de API só respondem se `Routes/api.php` existir; o prefixo é automático.
3. `InternalApiAuth` depende de `config('v2.internal_api.*')` (header, access_key, default_scope).
4. A tabela `v2_internal_api_keys` precisa existir antes de emitir chaves; a migration do Core de
   usuários não é pré-requisito, mas `created_by` guarda o e-mail de suporte.
5. O comando é registrado globalmente em `app/Console/Kernel.php:22` (não há auto-discovery de
   comandos de módulo).
6. `NoticiasImportService` (DI) depende de `NoticiasImportNormalizer`, `GalleryService`,
   `RegionalIdentityService` e `ActivityLoggerService` (`NoticiasImportService.php:67-77`); sem
   `Gallery`/`ActivityLogger` o container falha ao resolver a action.

### 4.4 Receitas de uso

**Emitir chave para um coletor de PE, válida por 90 dias e restrita a um IP:**

```bash
php artisan v2:noticias-import-key --name="Coletor SUCESU-PE" --consumer=PE --ttl=90 --ips=203.0.113.10
# saída: id, prefix, consumer: PE, escopo: noticias:import, expira: <data>
#        SEGREDO (exibido uma única vez — guarde agora): <64 hex>
```

**Consumir o dry-run por API:** ver `contrato-api.md` §5.5 (exemplo `curl` completo). Resultado
esperado: `200` com `summary.planned == summary.total` e nenhuma escrita em `v2_news`.

**Aplicar de fato a partir do CMS:** abrir `/cms/importacao-noticias`, escolher a regional e o
status, clicar “Aplicar de fato” e confirmar. Resultado: `200` com `summary.created > 0`; a lista
mostra o badge `created` e o título importado (`Views/cms/index.blade.php:86-104`).

**Revogar e conferir:**

```bash
php artisan v2:noticias-import-key --revoke=<uuid>
php artisan v2:noticias-import-key --list
```

Resultado: próxima chamada com o segredo revogado responde `401 key_revoked`; `--list` mostra a
coluna `revoked_at`.

**`--ttl` — comportamento e validação recomendada:** o comando converte com `(int)` e não valida
(`Console/Commands/NoticiasImportKeyCommand.php:64`): `--ttl=abc` vira `0` e cria chave **perpétua**
(`V2/Core/Security/InternalApiKey.php:105`); `--ttl=-1` cria chave já expirada. A reimplementação deve
exigir inteiro `>= 0` (`filter_var(..., FILTER_VALIDATE_INT)`), abortar com
`Informe --ttl=<dias inteiros >= 0> (0 = sem expiração).` e código de saída `1`, tratando **apenas**
`0` como perpetuidade.

**Health check de conectividade (sem efeito de domínio):**

```bash
curl -s -H "X-Access-Key: $SECRET" https://<host>/api/v2/noticiasimport/health
# {"status":"ok","scope":"noticias:import"}
```

### 4.5 Anti-uso

1. **Reapontar a API das Unidades Regionais para `InternalImportKey`** — a regional autentica com
   Bearer por unidade (`v2_unidade_regional.api_token_hash`) e opera `pending_approval` no próprio
   escopo (`UnidadeRegionalNewsApiController.php:446-474`); a chave interna não tem
   `unidade_regional_id`, e o serviço não deriva a categoria regional a partir dela. Quebra o
   isolamento entre regionais.
2. **Usar a `ACCESS_KEY` estática em produção como credencial normal** — ela não tem TTL, revogação,
   allow-list nem auditoria de prefixo; além disso, com `key=null`, o `consumer` é ignorado e o
   default `'PE'` pode gravar na regional errada (`Controllers/NoticiasImportController.php:94-95`).
3. **Chamar `/run` com `apply=1` sem dry-run prévio** — o dry-run é o único passo que expõe
   `rejected`/`warnings` antes da escrita; o `apply` isola cada item em transação, mas itens válidos
   serão gravados sem revisão (`NoticiasImportService.php:154-164,227-239`).

### 4.6 Custos e limites da integração

- **Throttle**: 30 req/min em `/run`; `/health` ilimitado; balde por prefixo de Bearer ou IP
  (`ThrottleApiToken.php:24-49`).
- **Payload**: sem limite explícito de tamanho no controller; o limite efetivo é o do PHP/web server.
  `dir` é string simples.
- **Tempo**: `apply` é síncrono; o lote inteiro precisa caber no `max_execution_time`. Não há fila,
  job nem timeout próprio.
- **Volume**: uma transação por item; imagens até 10 MB cada (`NoticiasImportService.php:32-33`).
- **Consistência**: o page cache é limpo **depois** da execução e só se houve `created`
  (`NoticiasImportService.php:489-500`); falha nessa limpeza é logada e não reverte as notícias.
- **Quota de chaves**: sem limite; `--list` não pagina.

## 5. Rotas e contratos HTTP

| Tipo | Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|---|
| grupo | — | `api/v2/noticiasimport` | — | `api`, `InternalImportKey` | — | — | aplica a chave a todas as rotas do grupo | `V2ServiceProvider.php:309-313`; `Routes/api.php:12` |
| endpoint | GET | `/api/v2/noticiasimport/health` | `v2.noticiasimport.api.health` | `api`, `InternalImportKey` | `NoticiasImportController@apiHealth` | escopo `noticias:import` | `200 {status,scope}`; atualiza `last_used_at` | `Routes/api.php:13-14`; `Controllers/NoticiasImportController.php:67-73` |
| endpoint | POST | `/api/v2/noticiasimport/run` | `v2.noticiasimport.api.run` | `api`, `InternalImportKey`, `throttle.api:30,1` | `NoticiasImportController@apiRun` | escopo/abilities + `consumer` | dry-run/apply; relatório JSON | `Routes/api.php:16-18`; `Controllers/NoticiasImportController.php:57-60` |
| grupo | — | `cms/importacao-noticias` | — | `web`, `module.active:NoticiasImport`, `CmsAuthenticate` | — | grupo Support (e-mail) | — | `V2ServiceProvider.php:303-307`; `Routes/cms.php:10-13` |
| endpoint | GET | `/cms/importacao-noticias` | `v2.noticiasimport.cms.index` | idem | `NoticiasImportController@index` | e-mail de suporte | renderiza a tela | `Routes/cms.php:14` |
| endpoint | POST | `/cms/importacao-noticias/run` | `v2.noticiasimport.cms.run` | idem + CSRF | `NoticiasImportController@run` | e-mail de suporte | executa via sessão; JSON | `Routes/cms.php:15`; `Controllers/NoticiasImportController.php:45-48` |

Detalhes de payload, headers, códigos e corpos exatos estão em
[`contrato-api.md`](contrato-api.md) §§1–3 e §5.

## 6. Dados e modelo de domínio

### 6.1 `v2_internal_api_keys`

| Coluna | Tipo | Nulo | Default/observação |
|---|---|---|---|
| `id` | uuid | não | PK; preenchido por `v2_uuid()` no hook `creating` (`InternalApiKey.php:77-86`) |
| `name` | varchar(255) | não | descrição livre |
| `scope` | varchar(64) | não | indexado; `noticias:import` por padrão |
| `key_hash` | char(64) | não | **unique**; `sha256(secret)` |
| `key_prefix` | char(12) | não | indexado; 12 primeiros caracteres do segredo |
| `allowed_ips` | jsonb | sim | `null` = qualquer IP |
| `expires_at` | timestamp | sim | `null` = sem expiração |
| `revoked_at` | timestamp | sim | preenchido por `revoke()` |
| `last_used_at` | timestamp | sim | `markUsed()` |
| `created_by` | varchar(255) | sim | e-mail de suporte |
| `created_at`/`updated_at` | timestamp | sim | — |
| `deleted_at` | timestamp | sim | `SoftDeletes` |
| `consumer` | varchar(32) | sim | indexado; `null` = irrestrito (migration 2) |
| `abilities` | jsonb | sim | `null` = usa `scope` (migration 2) |

Índices: `scope`, `key_prefix`, `consumer`; único em `key_hash`; PK em `id`.
Fontes: `Migrations/2026_09_17_000002_create_v2_internal_api_keys_table.php:28-41`;
`Migrations/2026_09_18_000003_add_consumer_abilities_to_v2_internal_api_keys.php:30-38`.

Modelo: `V2\Core\Security\InternalApiKey` — `$table = 'v2_internal_api_keys'`,
`$incrementing = false`, `$keyType = 'string'`, `$fillable` com todas as colunas, casts
`abilities`/`allowed_ips` para array e as três datas para `datetime` (`InternalApiKey.php:32-68`).
O alias `V2\Modules\Support\NoticiasImport\Models\InternalImportKey` não altera tabela nem casts.

```mermaid
erDiagram
  V2_INTERNAL_API_KEYS {
    uuid id PK
    string name
    string scope
    char key_hash UK
    char key_prefix
    string consumer
    jsonb abilities
    jsonb allowed_ips
    timestamp expires_at
    timestamp revoked_at
    timestamp last_used_at
    string created_by
  }
```

Não há FK: a tabela é deliberadamente independente de usuário e de unidade regional. A ligação com
`v2_news_import_keys` é lógica, não relacional (ver `contrato-api.md` §6.3).

### 6.2 Envelope de resposta (relatório)

| Chave | Tipo | Origem |
|---|---|---|
| `context.dir` | string | caminho absoluto resolvido |
| `context.regional` | string | input resolvido |
| `context.status` | string | normalizado (`draft`/`published`) |
| `context.apply` | bool | normalizado |
| `context.aborted` / `abort_code` / `abort_message` | bool/string | `NoticiasImportReport::abort()` |
| `summary.total/counts/created/planned/skipped/rejected/failed` | int/objeto | `counts()` |
| `items[]` | array | um por subpasta `noticia*`; inclui `action`, `name`, `title`, `cover`, `body_images`, `warnings`, `errors`, `import_key_hash`, e `news_id`/`slug`/`message` quando aplicável |
| `duration_ms` | int | `microtime` |
| `generated_at` | string ISO-8601 | `date('c')` |

Fontes: `NoticiasImportReport.php:97-116`; `NoticiasImportService.php:176-196,241-249`.

Invariantes de dados: `key_hash` é único e imutável; `key_prefix` não identifica a chave sozinho
(colisão possível, por isso não é unique); `revoked_at` é terminal (não há “desrevogar”);
`scope` e `abilities` coexistem com precedência de `scope` exato.

## 7. Fluxos e estados

### 7.1 Ciclo de vida da chave

```mermaid
stateDiagram-v2
  [*] --> Ativa: issue() / --name
  Ativa --> Expirada: now > expires_at
  Ativa --> Revogada: revoke() / --revoke
  Expirada --> Revogada: revoke()
  Ativa --> Ativa: markUsed() (last_used_at)
  Revogada --> [*]
```

`isActive()` = não revogada e não expirada (`InternalApiKey.php:143-146`), mas o middleware não usa
`isActive()`: checa cada condição separadamente para devolver o código específico
(`InternalApiAuth.php:72-90`).

### 7.2 Execução de importação (API)

```mermaid
sequenceDiagram
  participant C as Coletor
  participant M as InternalImportKey
  participant A as InternalApiAuth
  participant T as ThrottleApiToken
  participant Ctl as NoticiasImportController
  participant S as NoticiasImportService
  C->>M: POST /api/v2/noticiasimport/run (X-Access-Key)
  M->>A: handle (escopo noticias:import)
  A->>A: findBySecret → revogada? expirada? IP? ability?
  A->>A: markUsed + audit + bind(internal_api_key)
  A-->>T: next
  T->>T: hit(prefix|ip, 60s)
  T->>Ctl: execute
  Ctl->>Ctl: resolveDir → consumerAllows(regional)
  Ctl->>S: run(dir, {regional,status,apply,author_*})
  S-->>Ctl: relatório
  Ctl-->>C: 200 ou 422 (context.aborted)
```

`FL-NOTICIASIMPORT-01` — dry-run: itens viram `planned`/`rejected`; nada em `v2_news`, nada em
`v2_news_import_keys` (`NoticiasImportService.php:154-164`).
`FL-NOTICIASIMPORT-02` — apply: `skipped` se o `import_key_hash` já existe; senão `created` (ou
`failed` com rollback do item) (`:219-250`).
`FL-NOTICIASIMPORT-03` — regional inexistente: `abort()` antes de iterar; HTTP 422 (`:100-105`).

## 8. UI CMS

| Tela | Rota | Permissão | Layout | Objetivo |
|---|---|---|---|---|
| Importação de Notícias | `GET /cms/importacao-noticias` | e-mail `config('v2.support.email')` + papel Sentinel | `v2-cms::layouts.app` | disparar dry-run/apply e ler o relatório |

Campos:

| Nome | DOM | Tipo | Obrigatório | Default | Validação | Ajuda |
|---|---|---|---|---|---|---|
| Diretório do lote | `#import-dir` | texto | sim (na prática) | `storage/app/imports/sucesu_pe_noticias` | só server-side (`resolveDir`) | “Permitido: `storage/app/imports/*` ou `TMP/*`.” (`Views/cms/index.blade.php:28`) |
| Regional | `#import-regional` | select | sim | `PE` (se existir na lista) | nenhuma no client; server resolve/aborta | — |
| Status | `#import-status` | select | sim | `draft` (primeira opção) | server normaliza | — |

Estados: inicial sem relatório (`#import-report` vazio); “executando” com alerta `info`; sucesso com
`alert-success` e tabela; erro com `alert-danger` e texto do `message`. Loading não desabilita
botões — cliques repetidos disparam múltiplas requisições.

Listagem do relatório: colunas Ação (badge), Item, Título, Capa, Imagens, Avisos
(`:86-104`); sem ordenação, filtro, paginação ou bulk actions. Badges:
`created=success`, `planned=primary`, `skipped=secondary`, `rejected=warning`, `failed=danger`.

Acessibilidade: labels explícitos, `aria-describedby`, `role="status"` + `aria-live="polite"` no
feedback, `caption` visualmente oculta na tabela, ícones decorativos sem `aria-hidden` (dívida).

## 9. UI Portal

Não se aplica: o módulo não registra `Routes/web.php` nem view de portal; `Views/` contém apenas
`cms/index.blade.php`. Nenhum dado desta unidade é exibido publicamente.

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

### 10.1 Chaves de configuração

| Chave | Default | Efeito | Consumidor |
|---|---|---|---|
| `v2.internal_api.header` | `X-Access-Key` (`env V2_INTERNAL_API_HEADER`) | primeiro header procurado | `InternalApiAuth.php:171-174` |
| `v2.internal_api.access_key` | `env V2_INTERNAL_API_ACCESS_KEY` (vazio = desligada) | chave estática de bootstrap, sem registro | `InternalApiAuth.php:149-154` |
| `v2.internal_api.default_scope` | `noticias:import` | escopo quando a rota não passa parâmetro | `InternalApiAuth.php:161-164` |
| `v2.internal_api.ttl_days` | `90` (`V2_INTERNAL_API_KEY_TTL_DAYS`) | **sem uso** no código atual | — |
| `v2.internal_api.max_attempts` | `30` (`V2_INTERNAL_API_KEY_MAX_ATTEMPTS`) | **sem uso**; throttle é literal `30,1` na rota | — |
| `v2.internal_import_keys.scope_noticias` | `noticias:import` | escopo exigido pelo wrapper e gravado em novas chaves | `Middleware/InternalImportKey.php:43-46`; comando; `/health` |
| `v2.internal_import_keys.ttl_days` | `90` (`V2_INTERNAL_IMPORT_KEY_TTL_DAYS`) | **sem uso**; `--ttl` fixa 90 | — |
| `v2.internal_import_keys.max_attempts` | `30` (`V2_INTERNAL_IMPORT_KEY_MAX_ATTEMPTS`) | **sem uso** | — |
| `v2.support.email` | `suporte@maxima.inf.br` (`V2_SUPPORT_EMAIL`) | gate de grupo Support, `created_by` e log de acesso negado | `V2PermissionMiddleware.php:52`; `SidebarComposer.php:44`; comando `:66` |
| `v2.permissions.support_group` | `SUPPORT` | rótulo nominal; o middleware compara a string `'SUPPORT'` hardcoded | `V2PermissionMiddleware.php:51` |

### 10.2 Permissões e autorização

| Controle | Tipo | Onde | Fonte |
|---|---|---|---|
| Grupo `Support` | bloqueio por e-mail, antes do RBAC | `V2PermissionMiddleware` | `:50-67` |
| Chave de módulo `NoticiasImport` | RBAC V2 — **nunca consultada** para o e-mail de suporte, pois o grupo Support retorna antes; para qualquer outro e-mail o acesso já foi negado | `V2PermissionMiddleware` | `:50-85` |
| `noticias:import` | escopo/ability da chave | `InternalApiAuth` | `:87-90`; `InternalApiKey.php:185-204` |
| `consumer` | recorte de regional | controller | `Controllers/NoticiasImportController.php:98-103` |
| `allowed_ips` | allow-list por IP | `InternalApiAuth` | `:82-85` |

### 10.3 `ModuleProvider::getConfig()`

| Chave | Valor | Efeito |
|---|---|---|
| `title` | `Importação de Notícias` | rótulo na sidebar (ordenado por título) |
| `description` | `Importa lotes de notícias de coletores externos (dry-run + apply).` | não renderizado pela sidebar |
| `icon` | `bi bi-cloud-arrow-down` | ícone do menu |
| `position` | `90` | não usado na ordenação |
| `active_pattern` | `['cms/importacao-noticias*']` | casa a rota no RBAC e dispara o bloqueio Support |
| `route` | `v2.noticiasimport.cms.index` | rota do item de menu |
| `subModulesLevel1` | 1 item `Importar Lote` → mesma rota | menu achatado (nível 2 ausente) |
| `group` | injetado (`Support`) pelo provider | define restrição e agrupamento |

Fonte: `ModuleProvider.php:19-41`; injeção de `group` em `V2ServiceProvider.php:109`.

### 10.4 Flag de módulo

O provider aplica `module.active:NoticiasImport` automaticamente (`V2ServiceProvider.php:304`).
Sem registro em `v2_module_flags`, `ModuleFlagService::isActive()` devolve `true` por compatibilidade
(`V2/Core/Services/ModuleFlagService.php:93-94`). Não há flag semeada para este módulo.

Desativado: JSON/AJAX recebe `404 {"error":"Modulo desativado: NoticiasImport","code":404}`; o
restante recebe `abort(404, 'Modulo desativado.')` (`ModuleAccessMiddleware.php:46-62`). O redirect
ao dashboard com `Módulo "NoticiasImport" está desativado. Ative-o em Suporte Técnico > Módulos.`
**não ocorre** aqui: a condição exige `$request->route()->getPrefix() === 'cms'`
(`:56-59`), e o prefixo real desta rota é `cms/importacao-noticias`. Como o middleware roda antes do
`CmsAuthenticate`, um anônimo também recebe 404, não o redirect de login.

## 11. Observabilidade, erros e limites

| Log | Nível | Conteúdo | Fonte |
|---|---|---|---|
| `[InternalApiAuth] missing_key/invalid_key/key_revoked/key_expired/ip_not_allowed/scope_not_allowed/authorized/authorized_static` | warning/info | `module=internal_api`, `action=internal_key_auth`, `result`, `key_prefix`, `key_id`, `consumer`, `ip`, `method`, `path`, `ts` — **nunca** o segredo | `InternalApiAuth.php:218-232` |
| `[V2 > MIDDLEWARE > Permission] Acesso negado ao grupo restrito Support ...` | warning | e-mail, módulo, URL | `V2PermissionMiddleware.php:53-58` |
| `[NoticiasImport] Execução concluída` | info | regional, ator, total, counts, apply, duração | `NoticiasImportService.php:543-559` |
| `[NoticiasImport] Falha ao importar item` | error | nome, `source_url`, mensagem | `NoticiasImportService.php:511-523` |
| `[NoticiasImport] Falha ao limpar page cache` | warning | mensagem | `NoticiasImportService.php:497-501` |

Auditoria de domínio: `ActivityLogger` registra `imported` por notícia criada
(`NoticiasImportService.php:244-249`) — não registra a chamada HTTP nem o ator da chave.

Códigos HTTP: `200`, `401` (`missing_key`, `invalid_key`, `key_revoked`, `key_expired`), `403`
(`ip_not_allowed`, `scope_not_allowed`, `consumer_mismatch`), `422` (`invalid_dir`,
`regional_not_found` e demais abortos), `429 too_many_requests`, `419` CSRF no CMS, `404` módulo
desativado, `403 Forbidden. Support area is restricted.` no CMS para não-suporte.
Fail-closed em toda a autenticação; fail-open **apenas** na flag de módulo ausente.

Limites: throttle 30/min em `/run` **apenas para requisições autorizadas** — 401/403 retornam antes
do limiter (`InternalApiAuth.php:42-96`), não consomem cota e cada tentativa gera auditoria; imagens
10 MB; sem paginação em `--list`; sem limite de chaves; sem timeout próprio; execução síncrona;
`apply` abre uma transação por item.

## 12. Seeds e dados iniciais

Não se aplica: não há seeder, nem role/permissão semeada, nem chave criada por migration. As chaves
são emitidas exclusivamente por `v2:noticias-import-key` (`Console/Commands/NoticiasImportKeyCommand.php:17-24`).
O `DatabaseSeeder` não referencia este módulo nem `v2_internal_api_keys`.

## 13. Lacunas

- **LG-NOTICIASIMPORT-01** — Não há valor conhecido de produção para `V2_INTERNAL_API_ACCESS_KEY`
  nem evidência de uso. Procurar em `.env`/deploy; impacto: saber se o bootstrap está ligado.
- **LG-NOTICIASIMPORT-02** — Não se sabe quem são os coletores reais e quais IPs usam; a allow-list
  é opcional. Procurar na operação/documentação de integração; impacto: definir `--ips` e `--ttl`.
- **LG-NOTICIASIMPORT-03** — Não existe teste do caminho CMS `POST /run` nem de `--abilities`/`--ips`;
  só há testes de API (`tests/Feature/V2/Modules/Support/NoticiasImport/NoticiasImportTransportTest.php:98-194`).
- **LG-NOTICIASIMPORT-04** — O diretório `TMP` usado pelos testes (`base_path('TMP/sucesu_pe_noticias')`,
  `:111`) não está versionado; não é possível reproduzir o lote sem fixture externa. Impacto: teste
  de integração depende de dado fora do repositório.
- **LG-NOTICIASIMPORT-05** — Não foi localizada rotação automática de chaves nem alerta de expiração;
  `last_used_at` não é exibido em nenhuma tela. Procurar em `SupportConfigs`/jobs; impacto: chaves
  podem expirar sem aviso.
- **LG-NOTICIASIMPORT-06** — Não há definição de comportamento esperado para `--ttl` negativo
  (`now()->addDays(-n)` cria chave já expirada) além do efeito óbvio.

## 14. Checklist de paridade

- [ ] `PC-NOTICIASIMPORT-01` — `GET /cms/importacao-noticias` como suporte devolve `200` com os IDs `import-dir`, `import-regional`, `import-status`, `btn-dry-run`, `btn-apply`. Fonte: `Views/cms/index.blade.php:25-56`.
- [ ] `PC-NOTICIASIMPORT-02` — Usuário autenticado sem ser o e-mail de suporte recebe `403` (JSON) ou redirect com flash. Fonte: `V2PermissionMiddleware.php:50-67`.
- [ ] `PC-NOTICIASIMPORT-03` — `GET /api/v2/noticiasimport/health` com chave válida devolve `200 {"status":"ok","scope":"noticias:import"}`. Fonte: `Controllers/NoticiasImportController.php:67-73`.
- [ ] `PC-NOTICIASIMPORT-04` — Sem chave: `401 missing_key`; chave inexistente: `401 invalid_key`; revogada: `401 key_revoked`; expirada: `401 key_expired`. Fonte: `InternalApiAuth.php:42-80`.
- [ ] `PC-NOTICIASIMPORT-05` — IP fora de `allowed_ips`: `403 ip_not_allowed`; escopo divergente: `403 scope_not_allowed`. Fonte: `:82-90`.
- [ ] `PC-NOTICIASIMPORT-06` — `POST /run` com `dir` fora de `storage/app/imports`/`TMP` devolve `422 invalid_dir` (inclusive `/etc`). Fonte: `Controllers/NoticiasImportController.php:86-91,124-141`; teste `NoticiasImportTransportTest.php:129-140`.
- [ ] `PC-NOTICIASIMPORT-07` — Chave com `consumer=PE` e payload `regional=RJ` devolve `403 consumer_mismatch`. Fonte: `Controllers/NoticiasImportController.php:98-103`; teste `:179-194`.
- [ ] `PC-NOTICIASIMPORT-08` — `apply` ausente/falso não cria linha em `v2_news` nem em `v2_news_import_keys`. Fonte: `NoticiasImportService.php:154-164`; teste `:98-124`.
- [ ] `PC-NOTICIASIMPORT-09` — Regional inexistente devolve `422` com `context.aborted=true` e `abort_code=regional_not_found`. Fonte: `NoticiasImportService.php:100-105`; `Controllers/NoticiasImportController.php:113-115`.
- [ ] `PC-NOTICIASIMPORT-10` — `v2:noticias-import-key --name=X --consumer=pe` grava `consumer="PE"` e exibe o segredo uma única vez. Fonte: `InternalApiKey.php:100,110`; `NoticiasImportKeyCommand.php:72-81`.
- [ ] `PC-NOTICIASIMPORT-11` — `--revoke=<uuid>` preenche `revoked_at` e mantém a linha listável. Fonte: `InternalApiKey.php:237-241`; comando `:92-105`.
- [ ] `PC-NOTICIASIMPORT-12` — 31 chamadas/min **autorizadas** a `/run` devolvem `429 too_many_requests` com `Retry-After`; tentativas com chave inválida nunca geram `429` nem `X-RateLimit-*` (ver PC-29). Fonte: `ThrottleApiToken.php:36-49`; `Routes/api.php:17`; `InternalApiAuth.php:42-58`.
- [ ] `PC-NOTICIASIMPORT-13` — `status=pending_approval` ou desconhecido resulta em `draft` no relatório. Fonte: `NoticiasImportService.php:129-137`.
- [ ] `PC-NOTICIASIMPORT-14` — `X-Access-Key` estática de `config('v2.internal_api.access_key')` autoriza sem linha no banco; segredo errado devolve `401`. Fonte: `InternalApiAuth.php:47-49,149-154`; teste `:161-174`.
- [ ] `PC-NOTICIASIMPORT-28` — `v2:noticias-import-key --name=X --ttl=abc` e `--ttl=-1` **não** podem ser aceitos silenciosamente como `0`/expirado; comando deve abortar com exit `1`. Hoje cria chave perpétua/expirada (`NoticiasImportKeyCommand.php:64`; `InternalApiKey.php:105`).
- [ ] `PC-NOTICIASIMPORT-29` — Throttle/consumo: 10 falhas de chave não geram `429` nem `X-RateLimit-*`, e cada uma gera log; confinamento e teste de lote cruzado estão em `PC-NOTICIASIMPORT-25` a `PC-NOTICIASIMPORT-27` de `contrato-api.md` §14. Fonte: `InternalApiAuth.php:42-58,82-96,218-232`.

## 15. Evidências consultadas

- `V2/Modules/Support/NoticiasImport/ModuleProvider.php:19-41`
- `V2/Modules/Support/NoticiasImport/Routes/api.php:1-19`; `Routes/cms.php:1-16`
- `V2/Modules/Support/NoticiasImport/Controllers/NoticiasImportController.php:19-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:22-72`
- `V2/Modules/Support/NoticiasImport/Migrations/2026_09_18_000003_add_consumer_abilities_to_v2_internal_api_keys.php:24-77`
- `V2/Modules/Support/NoticiasImport/Views/cms/index.blade.php:1-143`
- `V2/Modules/Support/NoticiasImport/MODULE.md:1-94`
- `V2/Core/Security/InternalApiKey.php:1-242`
- `V2/Core/Middleware/InternalApiAuth.php:1-233`
- `V2/Core/Middleware/V2PermissionMiddleware.php:15-95`
- `V2/Core/Middleware/CmsAuthenticate.php:1-44`
- `V2/Core/Middleware/ThrottleApiToken.php:1-59`
- `V2/Core/Middleware/ModuleAccessMiddleware.php:20-66`
- `V2/Core/Providers/V2ServiceProvider.php:84-142,236-320`
- `V2/Core/ViewComposers/SidebarComposer.php:30-101`
- `config/v2.php:4-52`
- `app/Http/Kernel.php:30-68`; `app/Console/Kernel.php:15-28`
- `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:27-115,129-164,176-250,451-468,489-570`
- `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportReport.php:1-116`
- `V2/Modules/Publicacoes/Noticias/Models/News.php:87-99`; `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/Publicacoes/Noticias/Console/Commands/NoticiasImportarLote.php:14-47`
- `V2/Modules/Conexoes/UnidadeRegional/Routes/api.php:1-38`; `Controllers/UnidadeRegionalNewsApiController.php:446-474`
- `tests/Feature/V2/Modules/Support/NoticiasImport/NoticiasImportTransportTest.php:18-194`
- `_qa/security-g1.md:96-107`
