---
id: MOD-CONEXOES-UNIDADEREGIONAL-API
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-CONEXOES-UNIDADEREGIONAL]
fontes:
  - V2/Modules/Conexoes/UnidadeRegional/
depende_de: [MOD-CONEXOES-UNIDADEREGIONAL, CORE-AUTH, CORE-RUNTIME, SVC-GALLERY, SVC-CONTENTRICHEDITOR, MOD-PUBLICACOES-NOTICIAS, MOD-CONEXOES-ASSOCIADOS]
consumidores: [MOD-PUBLICACOES-NOTICIAS, MOD-CONEXOES-ASSOCIADOS, SVC-SEARCH, SVC-SEO]
criticidade: alta
dono: Regional
---

# Conexoes/UnidadeRegional — API das Regionais

> Contrato completo do canal `Bearer asc_...` sob `/api/v2/unidaderegional`, incluindo
> autenticação, escopo por unidade, validações, payloads, erros, rate limit e OpenAPI.
> As rotas e middlewares estão em [`rotas.md`](rotas.md); o schema em [`dados.md`](dados.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Permite que cada unidade regional publique notícias (sempre `pending_approval`) e gerencie seus associados na base nacional, autenticando-se com um token Bearer exclusivo da unidade. |
| Entrada | 8 endpoints autenticados + `GET /openapi.json` público; corpo JSON; header `Authorization: Bearer asc_...`. |
| Saída | JSON no envelope `ApiResponse` (`ok`, `data`, `message`, `error`) ou erro de validação do Laravel; escritas em `v2_news`, `v2_associado_instituicoes`, `v2_unidade_regional_empresas`, `v2_gallery_media`. |
| Efeitos colaterais | Categoria regional derivada, tag da sigla, conversão Markdown→Tiptap, download/armazenamento de imagens, limpeza do page cache, atividade e log; `last_used_at` regravado a cada request. |
| Particularidade central | Token **sem escopos**: um único Bearer dá acesso a todos os 8 endpoints, limitado apenas pela unidade; TTL fixo em 365 dias porque a chave de config não existe (SEC-G1-14); GET/DELETE sem `throttle.api`. |
| Como se integra | Controllers especializados por recurso; `RegionalIdentityService` para categoria; `News`/`AssociadoInstituicao` como domínio; `GalleryService::seedFromPath` para imagens; `MarkdownToTiptapConverter` para conteúdo. |
| Se removido, quebra | Integrações das regionais, o guia `wiki/api/unidade-regional.md`, os exemplos do CMS e os testes `UnidadeRegional*ApiControllerTest`. |
| Superfície pública | 8 endpoints + spec OpenAPI 3.0.3; classes `UnidadeRegionalNewsApiController`, `UnidadeRegionalEmpresaApiController`, `UnidadeRegionalApiController`, requests `ApiNewsRequest`, `ApiEmpresaRequest`, model `UnidadeRegional`. |

## 1. Efeito

### 1.1 O que o portador do token consegue fazer

| Ação | Endpoint | Efeito |
|---|---|---|
| listar suas notícias | `GET /noticias` | `200` com `unidade_regional_id`, `total`, `current_page`, `last_page`, `news[]` |
| criar notícia | `POST /noticias` | `201`; notícia `pending_approval` vinculada à unidade |
| atualizar notícia | `POST /noticias/{id}` | `200`; **exige `title` e `content`** |
| arquivar notícia | `DELETE /noticias/{id}` | `200`; soft delete, some do CMS/portal |
| listar associados | `GET /associados` | `200` com vínculos, `status`, tags |
| criar/vincular associados | `POST /associados` | `201`; cria novo ou vincula **qualquer** associado nacional ativo por UUID (`empresa_ids`), sem checar origem/dono (§3.6.2) |
| atualizar associado | `POST /associados/{id}` | `200`; `{id}` = UUID do **associado**; exige vínculo existente (não necessariamente `active`) |
| arquivar associado | `DELETE /associados/{id}` | `200`; `{id}` = UUID do **vínculo**; arquiva também na base nacional |
| ler a spec | `GET /openapi.json` | `200` OpenAPI 3.0.3, sem auth, cache 1 h |

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| `v2_news` com `status='pending_approval'`, `author_id=null`, `unidade_regional_id`, `metadata.source*` | `createNoticia()` | `Controllers/UnidadeRegionalNewsApiController.php:209-249` |
| `v2_news_categories` (cria/renomeia `regional-<sigla>`) | `assignRegionaisCategory()` → `RegionalIdentityService` | `:555-570`; `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:33-61` |
| Tag da sigla em `v2_news_tags` + pivô da notícia | `assignAcronymTag()` | `:577-589` |
| `v2_gallery_media` para capa e imagens inline (MIME allowlist aplicada; download remoto sem controles de SSRF — ver §3.6) | `downloadAndStoreImage()` | `:479-535` |
| `v2_news.content` como JSON Tiptap e `metadata.source_markdown` com o Markdown original | `convertContent()` | `:411-437` |
| `v2_associado_instituicoes` + `v2_unidade_regional_empresas` | `createEmpresa()`/`linkEmpresa()` | `Controllers/UnidadeRegionalEmpresaApiController.php:382-417,468-496` |
| Soft delete do associado nacional + do vínculo | `destroy()` | `:194-201` |
| `api_token_last_used_at` a cada request | `authenticateToken()` | News `:466-467`; Empresa `:366-367` |
| `page:*` apagado no Redis | `clearPageCache()` | News `:596-608`; Empresa `:516-528` |
| `activities` com `news_api_create`, `news_api_update`, `unidade_regional_noticia_archive`, `unidade_regional_associados_sync`, `unidade_regional_associado_update/archive/link_remove` | `ActivityLoggerService::log()` | controllers citados |

### 1.3 Contrato observável (payload → resposta)

| Entrada | Saída |
|---|---|
| `POST /noticias {"title":"T","content":"# markdown"}` | `201 {"ok":true,"data":{"id":"<uuid>","title":"T","slug":"...","status":"pending_approval","created_at":"..."},"message":"Notícia recebida e pendente de aprovação.","error":null}` |
| `POST /noticias` sem `content` | `422 {"message":"The given data was invalid.","errors":{"content":["O conteúdo (markdown) é obrigatório ao criar uma notícia."]}}` |
| `POST /noticias` com `published_at` futuro | `201`; `published_at` gravado, **status continua `pending_approval`** |
| `POST /noticias/{id}` com `title` e `content` iguais | `200 {"data":{"id":"...","title":"..."},"message":"Nenhuma alteração detectada."}` |
| `POST /noticias/{id}` inexistente ou de outra unidade | `404 {"ok":false,"error":{"type":"not_found","title":"not_found","status":404,...},"message":"Notícia não encontrada ou não pertence a esta unidade regional."}` |
| `DELETE /noticias/{id}` já arquivada | `409 {"error":{"type":"already_archived",...},"message":"Esta notícia já está arquivada."}` |
| `POST /associados {"name":"X","tags":"a,b"}` | `201 {"data":{"results":[{"empresa_id":"<uuid>","success":true,"action":"created"}]},"message":"Associados sincronizados com sucesso."}` |
| `POST /associados {"empresa_ids":["<uuid-invalido>"]}` | `201` com `results[0] = {"empresa_id":"...","success":false,"error":"Associado não encontrado"}` |
| `POST /associados/{id}` de associado não vinculado | `403 {"error":{"type":"forbidden",...},"message":"Este associado não está vinculado à sua unidade regional."}` |
| `DELETE /associados/{id}` sem vínculo | `404 {"message":"Relação associado-regional não encontrada."}` |
| Sem `Authorization` | `401 {"error":{"type":"invalid_token","title":"Token não fornecido"},"message":"Token de autenticação não fornecido."}` |
| Token expirado | `401 {"error":{"type":"token_expired","title":"Token expirado"},"message":"Token de API expirado. Renove o token no CMS da SUCESU Nacional."}` |
| 31º POST/min com o mesmo token | `429 {"error":"too_many_requests","message":"Limite de requisições excedido. Tente novamente em <n> segundos.","retry_after":<n>}` |

### 1.4 O que a API NÃO faz

- **Não publica**: não existe endpoint para mudar `status`; a aprovação é do CMS de Notícias.
- **Não aceita `category_id`** (não está nas regras e é sobrescrito).
- **Não cria tags reais a partir do campo `tags`**: o array vai para `metadata.tags`; só a tag
  da sigla é persistida como tag.
- **Não gerencia a unidade**: nome, sigla, UF, redes sociais e galeria são exclusivos do CMS.
- **Não lista nem cria unidades**; não há endpoint de descoberta.
- **Não tem escopos/abilities**: o token não pode ser restringido a “somente notícias”.
- **Não aplica throttle em GET/DELETE** (SEC-G1-14).
- **Não usa transações**: uma criação de associado com tags pode persistir parcialmente se a
  sincronização de tags falhar.

## 2. Abordagem

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

```mermaid
sequenceDiagram
  participant C as Cliente
  participant R as Router api/v2/unidaderegional
  participant T as ThrottleApiToken
  participant V as FormRequest
  participant A as authenticateToken
  participant D as Domínio
  C->>R: POST /noticias + Bearer asc_X
  R->>T: throttle.api:30,1 (só POST)
  T->>V: ApiNewsRequest
  V->>A: payload válido
  A->>A: sha256 + lookup + expiração
  A->>A: last_used_at = now(); save()
  A->>D: UnidadeRegional autenticada
  D->>D: imagem → Markdown→Tiptap → categoria → save → tag
  D-->>C: 201 pending_approval
```

A autenticação é feita **dentro de cada controller** (método privado repetido), não por
middleware: não há `auth:api` nem guard configurado para este canal.

### 2.2 Mecânica passo a passo

1. `V2ServiceProvider` aplica prefixo `api/v2/unidaderegional` e grupo `api` (`bindings`) —
   `V2/Core/Providers/V2ServiceProvider.php:309-313`.
2. `Routes/api.php` aplica `throttle.api:30,1` + `throttle:100,1` nos quatro POST
   (`Routes/api.php:7-24`). O `ThrottleApiToken` monta a chave com
   `substr($token, 0, 12)` e devolve `429` com `retry_after` (`V2/Core/Middleware/ThrottleApiToken.php:30-47`).
3. `ApiNewsRequest`/`ApiEmpresaRequest` validam **antes** do controller (injeção do FormRequest).
4. `authenticateToken()`: extrai Bearer, calcula sha256, busca `UnidadeRegional` por
   `api_token_hash`, checa `isApiTokenValid()`, atualiza `last_used_at` e salva.
5. `UnidadeRegionalNewsApiController@index`: pagina `News` por
   `metadata->source_unidade_regional_id`, `orderByDesc('created_at')`, `per_page` livre.
6. `store` → `createNoticia`: baixa capa (best-effort), converte Markdown, monta model com
   `pending_approval`, metadata, `assignRegionaisCategory()`, `save()`, `assignAcronymTag()`,
   activity, cache, `201`.
7. `update` → `updateNoticia`: busca por id **e** unidade (inclusive trashed), rejeita
   arquivada, aplica apenas campos presentes, converte conteúdo, atualiza metadata, salva se
   mudou, activity, cache, `200`.
8. `destroy`: busca por id **e** unidade (trashed), `404`/`409`, `delete()`, activity, cache, `200`.
9. `UnidadeRegionalEmpresaApiController@store`: se `empresa_ids` presente → `linkExistingEmpresas`
   (relatório por item; aceita qualquer associado nacional não arquivado), senão `createEmpresa` +
   `linkEmpresa` + tags; sempre `201`.
10. `updateAssociado`: `find` do associado (`withTrashed`), `409` se arquivado, verifica que
    **existe** vínculo (qualquer `status`), aplica apenas campos de texto/mídia; se nenhum deles
    mudou, retorna `Nenhuma alteração detectada.` **antes** de `tags`/`status`; senão salva,
    sincroniza tags e atualiza o status do vínculo.
11. `destroy` de associado: busca o vínculo por id e unidade, apaga o vínculo e faz soft delete
    do associado nacional.
12. `openapi()` monta o array e responde JSON com `Cache-Control: public, max-age=3600`
    (`Controllers/UnidadeRegionalApiController.php:25-347`).

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

- **Token por unidade com hash em coluna** — permite revogar sem tabela extra e exibir prefixo;
  o custo é não haver múltiplos tokens/escopos. `[C]`
- **Autenticação no controller, duplicada nos dois controllers** — evita middleware com
  dependência do model, mas duplica regra; a reimplementação deve extrair um middleware/guard
  mantendo o mesmo contrato de erros. `[I]`
- **Categoria derivada** — impede a regional de escolher categoria nacional; declarado no
  OpenAPI (`Controllers/UnidadeRegionalApiController.php:35-37`). `[C]`
- **Associado como cidadão de primeira classe da base nacional** — não há tabela “associado
  regional”; a origem é marcada em `metadata`. `[C]`
- **Imagens por URL, não multipart** — a API aceita `cover_image_url` e baixa o binário; não há
  upload multipart na API. `[C]`
- **`POST` para update** — clientes simples (curl/JS) evitam `PUT`/`PATCH`. `[I]`

### 2.4 Trechos de código pivô

```php
// V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:209-235 — criação canônica
$news->status = News::STATUS_PENDING_APPROVAL;
$news->author_id = null;
$news->cover_image_id = $coverImageId;
$news->responsible_name = $request->input('responsible_name', $request->input('author_name'));
$news->source = $request->input('source');
$news->content_date = $request->input('content_date');
$news->unidade_regional_id = $unidade->id;

$metadata = [
    'source'                       => 'api',
    'source_unidade_regional_id'   => $unidade->id,
    'source_unidade_regional_name' => $unidade->name,
    'source_unidade_regional_slug' => $unidade->slug,
    'source_markdown'              => $request->input('content'),
];
if ($request->filled('author_name')) {
    $metadata['author_name'] = $request->input('author_name');
}
if ($request->filled('tags')) {
    $metadata['tags'] = $request->input('tags');
}
$news->metadata = $metadata;
```

`author_id` é sempre `null` (não há usuário CMS associado à unidade), e `responsible_name`
cai para `author_name` quando ausente.

```php
// V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:281-315 — escopo e rejeição de arquivada
protected function updateNoticia($id, ApiNewsRequest $request, UnidadeRegional $unidade)
{
    $news = News::withTrashed()->where('id', $id)
        ->where('metadata->source_unidade_regional_id', $unidade->id)
        ->first();

    if (!$news) {
        return $this->error('not_found', 'Notícia não encontrada ou não pertence a esta unidade regional.', 404, 'not_found');
    }

    if ($news->trashed()) {
        return $this->error(
            'archived',
            'Não é possível reativar uma notícia arquivada na nacional.',
            409,
            'archived_cannot_reactivate'
        );
    }

    $changed = false;

    if (empty($news->unidade_regional_id)) {
        $news->unidade_regional_id = $unidade->id;
        $changed = true;
    }
```

O `withTrashed()` é o que permite distinguir `404` (não é da unidade) de `409` (é da unidade,
mas arquivada). Como a notícia criada pela via principal já tem a FK, o backfill só atinge
notícias legadas.

```php
// V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:411-437 — Markdown→Tiptap com imagens inline
protected function convertContent($markdown)
{
    $converter = $this->getConverter();
    $converter->setImageHandler(function ($src, $alt) {
        try {
            $result = $this->downloadAndStoreImage($src, 'unidade-regional', $alt);
            if ($result) {
                $media = \V2\Services\Gallery\Models\GalleryMedia::find($result);
                if ($media) {
                    return [
                        'type' => 'contentRichMedia',
                        'attrs' => [
                            'path' => $media->file_path,
                            'alt'  => $alt ?: 'Imagem',
                            'type' => 'image',
                        ],
                    ];
                }
            }
        } catch (\Exception $e) {
            Log::warning('[UnidadeRegionalNewsApi] Falha ao processar imagem inline: ' . $e->getMessage());
        }
        return null;
    });

    return $converter->convert($markdown);
}
```

Imagem inline que falha vira `null` (o nó é omitido), sem abortar a notícia.

```php
// V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalEmpresaApiController.php:115-123 — dois modos do POST
if ($request->has('empresa_ids')) {
    $results = $this->linkExistingEmpresas($request->input('empresa_ids', []), $unidade, $status, $tags);
} else {
    $empresa = $this->createEmpresa($request, $unidade);
    $results[] = $this->linkEmpresa($empresa, $unidade, $status);
    if ($tags) {
        $empresa->syncTagsFromString($tags);
    }
}
```

A presença de `empresa_ids` — mesmo vazio — muda o modo. `[]` falha na validação (`min:1`),
portanto não cria acidentalmente.

```php
// V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalEmpresaApiController.php:194-201 — efeito nacional do DELETE
$empresa = AssociadoInstituicao::find($relacao->empresa_id);

// Arquivar o vínculo
$relacao->delete();

// Arquivar o associado no sistema nacional (soft-delete)
if ($empresa && !$empresa->trashed()) {
    $empresa->delete();
```

Uma regional arquivando “seu” associado remove o associado da base nacional — inclusive para
outras regionais que o compartilham.

### 2.5 Algoritmos, consultas e custos

- `GET /noticias`: `where('metadata->source_unidade_regional_id', ...)` sem índice + `COUNT` de
  paginação.
- `GET /associados`: `with('empresa.tags')` → 2–3 queries, sem paginação (`get()`).
- `POST /noticias`: 1 download + 1 `finfo` + 1 `seedFromPath` (que grava arquivo e linha) por
  imagem; 1–3 queries de categoria; 1 `firstOrCreate` de tag.
- `POST /associados` com N ids: 1 `SELECT` por id + 1 `SELECT`/`INSERT` de vínculo por id
  (N+1 controlado).
- `authenticateToken` grava a unidade a cada request (hooks de busca/categoria inclusos).
- `clearPageCache` executa `KEYS` + `DEL` (O(n) no Redis) por escrita.

## 3. Particularidades

### 3.1 O que só existe aqui

- Token `asc_` por unidade com **prefixo exibível** e `last_used_at` por request.
- Categoria regional derivada + tag automática da sigla.
- Conversão Markdown→Tiptap no servidor com download de imagens inline (nenhum outro canal
  aceita Markdown puro).
- Dois modos de escrita de associado (criar vs vincular) no mesmo endpoint.

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-UNIDADEREGIONAL-A01 | Token = `asc_` + 60 chars; banco guarda sha256 (64) + prefixo (12) | segredo nunca é recuperável | `Models/UnidadeRegional.php:277-291` |
| RN-UNIDADEREGIONAL-A02 | TTL fixo de 365 dias (`config('v2.unidade_regional.api_token_ttl_days')` inexistente) | SEC-G1-14 | `Models/UnidadeRegional.php:283-285`; `config/v2.php:1-70` |
| RN-UNIDADEREGIONAL-A03 | Token válido = hash presente **e** (`expires_at` nulo **ou** futuro) | token sem data nunca expira | `Models/UnidadeRegional.php:255-264` |
| RN-UNIDADEREGIONAL-A04 | Revogação = zerar hash/prefixo/datas; não há campo `revoked_at` | auditoria fica só em `activities` | `Models/UnidadeRegional.php:293-303` |
| RN-UNIDADEREGIONAL-A05 | Não há escopos: o token vale para notícias **e** associados | impossível delegar acesso parcial | `Routes/api.php:3-31` |
| RN-UNIDADEREGIONAL-A06 | Recorte de notícias usa `metadata->source_unidade_regional_id`; associados usam `unidade_regional_id` | notícias criadas no CMS (só FK) não aparecem na API | `Controllers/UnidadeRegionalNewsApiController.php:69,153,284` |
| RN-UNIDADEREGIONAL-A07 | `POST /noticias/{id}` exige `title` e `content` (mesmo FormRequest do store) | “parcial” só entre os demais campos | `Requests/ApiNewsRequest.php:25-38` |
| RN-UNIDADEREGIONAL-A08 | `category_id` é ignorado e sobrescrito | impossível forçar categoria | `Controllers/UnidadeRegionalNewsApiController.php:555-570` |
| RN-UNIDADEREGIONAL-A09 | `published_at` é aceito mas não publica | portal usa `scopePublished` | `:237-239`; `V2/Modules/Publicacoes/Noticias/Models/News.php:391-393` |
| RN-UNIDADEREGIONAL-A10 | `POST /associados/{id}` = associado; `DELETE /associados/{id}` = vínculo | dois identificadores na mesma URI | `Controllers/UnidadeRegionalEmpresaApiController.php:238,186-189` |
| RN-UNIDADEREGIONAL-A11 | `DELETE` de associado arquiva a entidade nacional | efeito cruzado entre regionais | `:194-201` |
| RN-UNIDADEREGIONAL-A12 | `GET`/`DELETE` sem `throttle.api` | SEC-G1-14 | `Routes/api.php:4-6,15-16,26-30` |
| RN-UNIDADEREGIONAL-A13 | Throttle por `substr(token,0,12)`, não pelo hash | prefixos iguais compartilham balde | `V2/Core/Middleware/ThrottleApiToken.php:34-39` |
| RN-UNIDADEREGIONAL-A14 | O download remoto aceita qualquer esquema/host que o wrapper PHP resolva, segue até 3 redirects e não bloqueia IP privado/loopback/link-local; não há limite de bytes | SSRF e DoS de memória a partir de `cover_image_url` e de `<img src>` do Markdown | `Controllers/UnidadeRegionalNewsApiController.php:485-505`; `V2/Modules/Publicacoes/Noticias/Services/MarkdownToTiptapConverter.php:433-440` |
| RN-UNIDADEREGIONAL-A15 | `POST /associados/{id}` retorna "Nenhuma alteração detectada." antes de aplicar `tags`/`status` quando nenhum campo de texto mudou | falso sucesso para atualização só de tags/status | `Controllers/UnidadeRegionalEmpresaApiController.php:295-314` |
| RN-UNIDADEREGIONAL-A16 | Vínculo (`POST /associados` com `empresa_ids`) aceita qualquer associado nacional não arquivado; a edição exige apenas que o vínculo exista, sem `status='active'` e sem checar `metadata.source_unidade_regional_id` | qualquer token regional pode reivindicar/editar/arquivar UUID nacional conhecido | `Controllers/UnidadeRegionalEmpresaApiController.php:426-447,253-260` |

### 3.3 Casos-limite

- **Unidade sem `acronym`**: notícia criada sem categoria e sem tag; não há erro.
- **`content` vazio (`""`)**: `convert()` devolve documento vazio (`{"type":"doc","content":[]}`),
  mas a validação `required` rejeita a string vazia.
- **`cover_image_url` inválida**: validação `url` rejeita; URL válida que não baixa →
  `Log::warning` e notícia sem capa.
- **Imagem com MIME fora da allowlist**: `RuntimeException` capturada no `store`; notícia criada
  sem capa.
- **`tags` com mais de 10 itens ou item > 50 chars**: `422`.
- **`entreprise_ids` com associado arquivado**: retorna item `success=false` com
  `"Associado arquivado — não é possível vincular"` e **não** falha a requisição.
- **`empresa_ids` com id duplicado**: `linkEmpresa` faz `update` de status no segundo item
  (`action: updated`).
- **`description` vazia no update**: grava `{"type":"doc","content":[]}` e zera o markdown em
  metadata? Não: o ramo `else` só converte `''`; `source_description_markdown` permanece o
  anterior porque o metadata só é alterado no ramo não vazio (`:305-315`).
- **Notícia de outra unidade**: `404` (não `403`), evitando enumerar IDs.
- **Associado de outra unidade**: `403` explícito (informa que existe).
- **Arquivar duas vezes**: vínculo some → `404` na segunda chamada.
- **`empresa_ids` no endpoint de update**: a presença do campo faz `ApiEmpresaRequest` validar no modo “vincular” (`array|min:1|max:100`), mas `updateAssociado()` ignora a lista e atualiza apenas o associado do `{id}`.


### 3.4 Armadilhas de reimplementação

1. **Implementar `PUT`/`PATCH` em vez de `POST`**: o contrato publicado e os testes usam POST.
2. **Aceitar `category_id`**: quebra a garantia de segregação editorial.
3. **Usar `unidade_regional_id` no `GET /noticias`**: omitiria notícias legadas cujo único
   vínculo é a FK.
4. **Tratar `empresa_ids` vazio como “criar”**: hoje `[]` gera `422` (`min:1`).
5. **Retornar `403` para notícia de outra unidade**: o contrato é `404`.
6. **Remover o `save()` do `last_used_at`** sem revisar hooks: a unidade deixa de ser
   reindexada a cada request (mudança de comportamento do índice, não do contrato HTTP).
7. **Confiar em `Content-Type: application/json`**: a API não exige; FormRequest lê o body
   conforme o parser do Laravel.

### 3.5 Divergências e dívidas

| Divergência | Documento diz | Código faz | Fonte |
|---|---|---|---|
| Update parcial | OpenAPI/MODULE.md: “apenas campos enviados” | `title` e `content` obrigatórios | `Controllers/UnidadeRegionalApiController.php:162-203`; `Requests/ApiNewsRequest.php:25-38` |
| `{id}` do update de associado | OpenAPI: “UUID do vínculo (UnidadeRegionalEmpresa)” | busca `AssociadoInstituicao` por `id` | `Controllers/UnidadeRegionalApiController.php:280-292`; `Controllers/UnidadeRegionalEmpresaApiController.php:236-242` |
| Rate limit | OpenAPI: 30/min token + 100/min IP nos POST | correto; GET/DELETE sem limite | `Controllers/UnidadeRegionalApiController.php:38-40`; `Routes/api.php` |
| `category` no payload | OpenAPI e código devolvem `category` | coluna removida; sempre `null` | `Controllers/UnidadeRegionalEmpresaApiController.php:73`; migration `2024_01_01_000020:17-37` |
| TTL | MODULE.md: configurável | fixo em 365 | `MODULE.md:39`; `Models/UnidadeRegional.php:283-285` |
| Erros 422 | `ApiResponse` promete envelope | Laravel devolve `{message, errors}` | `Requests/*`; trait `ApiResponse` |

Dívidas: `authenticateToken` duplicado; `app()` inline para o conversor; ausência de transação
em `POST /associados` com múltiplos ids; download remoto sem fronteira de confiança (§3.6).

### 3.6 Riscos de download remoto (SSRF) — SEC-G4-01

`downloadAndStoreImage()` busca uma URL controlada pelo cliente em **dois** caminhos:

1. `cover_image_url` do corpo do `POST /noticias` (único com validação `url`, `ApiNewsRequest.php:30`);
2. **toda** imagem `<img src="...">` do Markdown, via `convertContent()` →
   `MarkdownToTiptapConverter::handleImageNode()` (`V2/Modules/Publicacoes/Noticias/Services/MarkdownToTiptapConverter.php:433-440`),
   **sem nenhuma validação de URL**.

```php
// V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:485-505 — superfície de SSRF
$tempPath = tempnam(sys_get_temp_dir(), 'unr_api_img_');
$context = stream_context_create([
    'http' => [
        'timeout'    => 15,
        'user_agent' => 'SUCESU-CMS-V2-API/1.0',
        'follow_location' => 1,
        'max_redirects' => 3,
    ],
    'ssl' => [
        'verify_peer'      => false,
        'verify_peer_name' => false,
    ],
]);

$imageContent = @file_get_contents($url, false, $context);
if ($imageContent === false) {
    @unlink($tempPath);
    throw new \RuntimeException("Não foi possível baixar a imagem: {$url}");
}

file_put_contents($tempPath, $imageContent);
```

Controles presentes e ausentes:

| Controle | Situação | Fonte |
|---|---|---|
| Allowlist de esquema (`http`/`https`) | **ausente** — `file_get_contents` aceita `file://`, `ftp://`, `php://`, `data://` conforme `allow_url_fopen` e wrappers habilitados | `:499` |
| Allowlist/denylist de host | **ausente** | `:479-505` |
| Bloqueio de IP privado/loopback/link-local (ex.: `127.0.0.1`, `10/8`, `169.254.169.254`) | **ausente**, inclusive após redirect | `:490-491` |
| Revalidação do destino após cada redirect | **ausente** — `follow_location=1` delega ao wrapper | `:490-491` |
| TLS verificado | **desligado** (`verify_peer=false`, `verify_peer_name=false`) → MITM | `:493-495` |
| Limite de bytes baixados | **ausente** — `file_get_contents` carrega a resposta inteira em memória; `GalleryConfig(..., 10240, false)` (`:526`) é apenas declarativo e o `GalleryService` não lê `getMaxFileSize()` | `:499,505,526` |
| Timeout | 15 s total | `:488` |
| Redirects | até 3 | `:491` |
| MIME allowlist | presente, mas **depois** do download (`jpeg/png/webp/gif`) — não é controle de SSRF | `:507-515` |

**Impacto:** um token regional válido (ou um Markdown malicioso enviado por ele) faz o servidor
da nacional emitir requisições para `127.0.0.1`, `169.254.169.254`, serviços internos ou qualquer
host alcançável; com wrappers habilitados pode ler arquivos locais (`file:///etc/passwd`). Uma
resposta arbitrariamente grande é carregada inteira em memória antes de qualquer verificação,
permitindo DoS. Como o MIME é checado depois de gravar o corpo em `tmp`, o efeito colateral
(requisição + escrita temporária + memória) já ocorreu.

**Recomendação para a reimplementação:** aceitar apenas `http`/`https`; resolver o host e
rejeitar faixas privadas, loopback, link-local, multicast e reservadas (IPv4 e IPv6), revalidando
**após cada redirect** (redirects manuais, sem `follow_location`); reativar TLS; limitar bytes
via `Content-Length` + leitura em streaming com teto; manter timeout curto e limite de redirects;
não aceitar credenciais na URL; preferencialmente baixar por um cliente com egress dedicado.

### 3.6.2 Vínculo de associado sem fronteira de confiança — SEC-G4-03

`POST /associados` com `empresa_ids` busca cada UUID em `AssociadoInstituicao::withTrashed()` e
aceita **qualquer** registro não arquivado (`:426-447`), sem verificar
`metadata.source_unidade_regional_id`, dono ou allowlist. O vínculo criado é apenas um pivô
(`linkEmpresa()`, `:468-496`). Na edição (`updateAssociado()`), a checagem é de **existência** do
vínculo (`:253-260`) — não de `status = 'active'`. Consequências: um token regional que conheça um
UUID nacional pode reivindicá-lo, alterar nome/descrição/tags e, via `DELETE`, arquivá-lo para
todas as regionais. Recomendação: restringir o vínculo a associados criados pela própria unidade
(`metadata.source_unidade_regional_id`) ou sem dono, com allowlist explícita; exigir
`status = 'active'` nas leituras/edições; e registrar a origem da reivindicação em auditoria.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `GET /api/v2/unidaderegional/noticias` | endpoint | query `per_page` | Bearer válido | integradores regionais |
| `POST /api/v2/unidaderegional/noticias` | endpoint | body `ApiNewsRequest` | Bearer + throttle | integradores |
| `POST /api/v2/unidaderegional/noticias/{id}` | endpoint | `{id}` UUID da notícia | Bearer + throttle | integradores |
| `DELETE /api/v2/unidaderegional/noticias/{id}` | endpoint | `{id}` UUID da notícia | Bearer | integradores |
| `GET /api/v2/unidaderegional/associados` | endpoint | — | Bearer | integradores |
| `POST /api/v2/unidaderegional/associados` | endpoint | `ApiEmpresaRequest` | Bearer + throttle | integradores |
| `POST /api/v2/unidaderegional/associados/{id}` | endpoint | `{id}` UUID do associado | Bearer + throttle + vínculo | integradores |
| `DELETE /api/v2/unidaderegional/associados/{id}` | endpoint | `{id}` UUID do vínculo | Bearer | integradores |
| `GET /api/v2/unidaderegional/openapi.json` | endpoint | — | pública | Swagger/Postman/SDKs |
| `UnidadeRegional::generateApiToken` | método | `(): string` | — | CMS |
| `UnidadeRegional::isApiTokenValid` | método | `(): bool` | — | controllers |

### 4.2 Contrato de UI/JS

- O CMS exibe os endpoints fixos na lateral da edição (`Views/cms/edit.blade.php:210-216`) —
  lista incompleta (não inclui os `POST /{id}` de update).
- O JS de geração/revogação lê `d.data.raw_token`, `d.data.prefix`, `d.data.expires_at` e `d.ok`.
- Swagger UI é carregado de CDN no CMS (`Views/cms/docs.blade.php:1013-1035`) usando
  `$openApiUrl = route('v2.unidade-regional.api.openapi')` (`:3`).

### 4.3 Ordem e ciclo de vida

1. `v2_unidade_regionais` e colunas de token migradas.
2. `v2_gallery_media` disponível para capa/inline.
3. `RegionalIdentityService` resolvível (model `NewsCategory` migrado).
4. `News` e `AssociadoInstituicao` migrados.
5. Redis disponível para `clearPageCache` (falha é tolerada).

### 4.4 Receitas de uso

```bash
# 1) Criar notícia em Markdown com capa remota
curl -s -X POST https://<host>/api/v2/unidaderegional/noticias \
  -H "Authorization: Bearer asc_XXXXXXXXXXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Regional publica novo convênio",
    "content": "## Convênio\n\nDesconto de **10%**.\n\n![Sede](https://exemplo.com/sede.jpg)",
    "summary": "Convênio regional",
    "cover_image_url": "https://exemplo.com/capa.jpg",
    "content_date": "2026-10-01",
    "tags": ["convenio", "df"]
  }'
```

Resultado: `201` com `status = pending_approval`; duas mídias em `v2_gallery_media` (capa e
inline); `metadata.source_markdown` preserva o texto; tag `suc-tst` (sigla) associada.

```bash
# 2) Vincular associados existentes
curl -s -X POST https://<host>/api/v2/unidaderegional/associados \
  -H "Authorization: Bearer asc_XXXXXXXXXXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"empresa_ids":["<uuid-1>","<uuid-2>"],"status":"active","tags":"destaque,regional"}'
```

Resultado: `201` com dois itens em `results`, cada um `action: created|updated`.

```php
// 3) Consumir a API de dentro do PHP (Guzzle)
$client = new \GuzzleHttp\Client(['base_uri' => config('app.url') . '/api/v2/unidaderegional/']);
$res = $client->get('noticias', [
    'headers' => ['Authorization' => 'Bearer ' . $rawToken],
    'query'   => ['per_page' => 50],
]);
$payload = json_decode((string) $res->getBody(), true); // ['ok'=>true,'data'=>[...]]
```

Resultado: lista da unidade autenticada, sem risco de vazar outra regional.

```php
// 4) Importar a spec no Postman/Swagger
$openapi = file_get_contents(config('app.url') . '/api/v2/unidaderegional/openapi.json');
```

Resultado: documento OpenAPI 3.0.3 com `bearerAuth` e 4 paths.

### 4.5 Anti-uso

- **Reutilizar o token após trocar de unidade**: o token é do registro; ao arquivar/revogar
  manualmente, todas as chamadas passam a `401`.
- **Guardar o `raw_token` em log**: ele só é retornado uma vez; o banco guarda apenas o hash.
- **Esperar que `DELETE /associados/{id}` apenas desvincule**: ele arquiva o associado nacional.
- **Enviar `category_id` para “corrigir” a categoria**: ignorado; a fonte é a sigla da unidade.

### 4.6 Custos e limites

| Item | Limite | Fonte |
|---|---|---|
| `content` | 100.000 chars | `ApiNewsRequest.php:27` |
| `title` | 255 | `:26` |
| `subtitle` | 500 | `:28` |
| `summary` | 1.000 | `:29` |
| `cover_image_url` | 500 chars, URL válida | `:30` |
| `source` | 2.000 | `:33` |
| `tags` | 10 itens × 50 chars | `:35-36` |
| `empresa_ids` | 1..100 | `ApiEmpresaRequest.php:34` |
| `description` do associado | sem limite | `:38` |
| Download de imagem | timeout 15 s, 3 redirects, MIME jpeg/png/webp/gif **após** o download; `GalleryConfig(..., 10240, false)` declara 10 MB mas o serviço não verifica o tamanho; sem allowlist de host/esquema nem bloqueio de IP privado (SSRF — §3.6) | News `:479-535` |
| Rate limit POST | 30/min por prefixo de token; 100/min por IP | `Routes/api.php`; `ThrottleApiToken.php:22` |
| `per_page` | sem teto | News `:69-71` |

## 5. Rotas e contratos HTTP

### 5.1 Endpoints e payloads

#### `GET /noticias`

| Item | Valor |
|---|---|
| Auth | Bearer |
| Query | `per_page` (default 15) |
| Resposta | `200` com `unidade_regional_id`, `total`, `current_page`, `last_page`, `news[{id,title,slug,status,created_at}]` |
| Escopo | `metadata->source_unidade_regional_id = unidade.id` |
| Ordenação | `created_at DESC` |

#### `POST /noticias`

| Campo | Tipo | Obrigatório | Regra |
|---|---|---|---|
| `title` | string | sim | `max:255` |
| `content` | string | sim | `max:100000` (Markdown) |
| `subtitle` | string | não | `max:500` |
| `summary` | string | não | `max:1000` |
| `cover_image_url` | string | não | `url`, `max:500` |
| `author_name` | string | não | `max:255` → `metadata.author_name` |
| `responsible_name` | string | não | `max:255` (fallback `author_name`) |
| `source` | string | não | `max:2000` |
| `content_date` | date | não | cast `date` |
| `tags` | array | não | `max:10`, itens `max:50` → `metadata.tags` |
| `published_at` | date | não | gravado sem publicar |

Resposta `201` com `id`, `title`, `slug`, `status`, `created_at`.

#### `POST /noticias/{id}`

Mesmos campos do `POST /noticias`, com `title` e `content` ainda obrigatórios. Aplica apenas o
que veio no body (exceto os obrigatórios). `{id}` é o UUID da notícia. Resposta `200` com `id`,
`title`, `slug`, `status`, `updated_at`; se nada mudou, `200` com `id` e `title` e mensagem
`Nenhuma alteração detectada.`

#### `DELETE /noticias/{id}`

Sem body. Resposta `200` com `data = null`. Erros `404 not_found`, `409 already_archived`.

#### `GET /associados`

Resposta `200` com `unidade_regional_id`, `total`, `associados[{id, empresa_id, status, nome,
slug, logo, category, tags[{id,name,slug,is_primary}], created_at}]`. `id` é o UUID do vínculo;
`category` é sempre `null` (coluna removida).

#### `POST /associados`

| Modo | Campos | Regras |
|---|---|---|
| Vincular | `empresa_ids[]` | `array`, `min:1`, `max:100`, itens `string|max:36` |
| Criar | `name` | `required` (sem `empresa_ids`), `max:255` |
| Ambos | `status` | `in:active,inactive` (default `active`) |
| Ambos | `tags` | `string|max:1000`, separadas por vírgula |
| Criar | `description` | Markdown livre → Tiptap |
| Criar | `url` | `url|max:255` |
| Criar | `discount_badge` | `max:255` |
| Criar | `benefits_list` | string livre |

Resposta `201` com `results[]` (`empresa_id`, `success`, `action` ou `error`).

#### `POST /associados/{id}`

`{id}` = UUID do **associado**. Campos: `name`, `description`, `url`, `discount_badge`,
`benefits_list`, `status`, `tags`. Exige que **exista** vínculo com a unidade — qualquer
`status`, não apenas `active` (`403` se não houver). Resposta `200` com `id`, `name`, `slug`,
`updated_at`; `409` se o associado estiver arquivado.

**Ordem de aplicação (SEC-G4-02):** o controller só marca `$changed` para `name`, `url`,
`discount_badge`, `benefits_list` e `description`. Se o corpo trouxer **apenas** `tags` e/ou
`status`, ele retorna `200 {"message":"Nenhuma alteração detectada."}` **antes** de
`syncTagsFromString()`/update do vínculo, sem aplicar nada. Para efetivar tags/status é preciso
enviar também ao menos um dos campos acima (ex.: o mesmo `name`).

#### `DELETE /associados/{id}`

`{id}` = UUID do **vínculo**. Resposta `200`; arquiva o vínculo e o associado nacional.

#### `GET /openapi.json`

`200 application/json`; `openapi: 3.0.3`, `info.version: 2.0.0`; `servers[0].url = url('api/v2/unidaderegional')`;
`security = [['bearerAuth' => []]]`; schemas `SuccessResponse`, `ErrorResponse`, `NewsItem`,
`AssociadoItem`; 4 paths (`/noticias`, `/noticias/{id}`, `/associados`, `/associados/{id}`);
`Cache-Control: public, max-age=3600` (`Controllers/UnidadeRegionalApiController.php:25-347`).

### 5.2 Stack de middleware por endpoint

```text
GET    /noticias          → api → bindings → (controller: Bearer)
POST   /noticias          → api → bindings → throttle.api:30,1 → throttle:100,1 → ApiNewsRequest
POST   /noticias/{id}     → idem
DELETE /noticias/{id}     → api → bindings
GET    /associados        → api → bindings
POST   /associados        → api → bindings → throttle.api:30,1 → throttle:100,1 → ApiEmpresaRequest
POST   /associados/{id}   → idem
DELETE /associados/{id}   → api → bindings
GET    /openapi.json      → api (fora do grupo bindings)
```

### 5.3 Códigos de erro

| Código | `error.type` | Mensagem exata | Origem |
|---|---|---|---|
| 401 | `invalid_token` | `Token de autenticação não fornecido.` | `:449` News / `:349` Empresa |
| 401 | `invalid_token` | `Token inválido ou unidade regional não encontrada.` | `:456` / `:356` |
| 401 | `token_expired` | `Token de API expirado. Renove o token no CMS da SUCESU Nacional.` | `:460-463` / `:360-363` |
| 403 | `forbidden` | `Este associado não está vinculado à sua unidade regional.` | Empresa `:259` |
| 404 | `not_found` | `Notícia não encontrada ou não pertence a esta unidade regional.` | News `:157,288` |
| 404 | `not_found` | `Associado não encontrado.` | Empresa `:241` |
| 404 | `not_found` | `Relação associado-regional não encontrada.` | Empresa `:191` |
| 409 | `archived_cannot_reactivate` | `Não é possível reativar uma notícia arquivada na nacional.` | News `:292-297` |
| 409 | `archived_cannot_reactivate` | `Não é possível reativar um associado arquivado na nacional.` | Empresa `:245-250` |
| 409 | `already_archived` | `Esta notícia já está arquivada.` | News `:161` |
| 422 | — | `{message, errors}` (Laravel) | FormRequests |
| 429 | — | `Limite de requisições excedido. Tente novamente em <n> segundos.` | `ThrottleApiToken.php:38-43` |
| 429 | — | corpo padrão do Laravel | `throttle:100,1` |

O envelope de sucesso/erro (`ApiResponse`) é:

```php
protected function error(string $errorTitle, string $detail = '', int $statusCode = 400, string $type = 'about:blank'): JsonResponse
{
    return response()->json([
        'ok'      => false,
        'data'    => null,
        'message' => $detail ?: $errorTitle,
        'error'   => [
            'type'     => $type,
            'title'    => $errorTitle,
            'status'   => $statusCode,
            'detail'   => $detail,
            'instance' => request()->path(),
        ],
    ], $statusCode);
}
```

Note a inversão: no envelope, `error.title` recebe o **título humano** e `error.type` recebe o
slug estável (`invalid_token`, `not_found`, …). `422` não usa esse envelope.

## 6. Dados e modelo de domínio

Tabelas tocadas (detalhe em [`dados.md`](dados.md) §6):

| Tabela | Operação | Campos |
|---|---|---|
| `v2_unidade_regionais` | read + update (`api_token_last_used_at`) | `id`, `name`, `acronym`, `slug`, `api_token_*` |
| `v2_news` | insert/update/soft delete | `title`, `subtitle`, `content`, `summary`, `status`, `cover_image_id`, `responsible_name`, `source`, `content_date`, `published_at`, `category_id`, `unidade_regional_id`, `metadata` |
| `v2_news_categories` | insert/update | `name`, `slug`, `parent_id` |
| `v2_news_tags` + pivô | insert/attach | `id`, `name`, `slug` |
| `v2_associado_instituicoes` | insert/update/soft delete | `name`, `description`, `url`, `discount_badge`, `benefits_list`, `sort_order`, `is_active`, `metadata` |
| `v2_unidade_regional_empresas` | insert/update/soft delete | `id`, `unidade_regional_id`, `empresa_id`, `status` |
| `v2_gallery_media` | insert (via `GalleryService`) | `file_path`, `mime_type`, `variants` |

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> pending_approval: POST /noticias
  pending_approval --> published: CMS de Notícias aprova (fora deste módulo)
  pending_approval --> arquivada: DELETE /noticias/{id}
  published --> arquivada: DELETE /noticias/{id}
  arquivada --> arquivada: DELETE novamente → 409
```

- **FL-UNIDADEREGIONAL-A01 (autenticação)**: Bearer → sha256 → lookup → validade →
  `last_used_at` + `save()` → controller.
- **FL-UNIDADEREGIONAL-A02 (criar notícia)**: validação → auth → capa → conversão → model →
  categoria → save → tag → activity → cache → `201`.
- **FL-UNIDADEREGIONAL-A03 (atualizar)**: validação → auth → lookup escopado → 404/409 →
  campos → conversão → save → activity → cache → `200`.
- **FL-UNIDADEREGIONAL-A04 (vincular associados)**: validação → auth → modo → por item
  (`find`, trashed, tags, link) → activity → cache → `201`.
- **FL-UNIDADEREGIONAL-A05 (arquivar associado)**: auth → vínculo → delete vínculo →
  soft delete associado → activity → cache → `200`.

## 8. UI CMS

A documentação da API no CMS está em [`cms.md`](cms.md) §8.4. A tela exibe 8 endpoints, tabela
de rate limit (30/min nos POST, “Sem limite” nos demais) e exemplos em PHP, Laravel, Node, Python,
Ruby e Rust (`Views/cms/docs.blade.php:145-193,283-905`).

## 9. UI Portal

A API não alimenta diretamente o portal: só grava `v2_news` (publicação depende do CMS de
Notícias). O portal consome os dados em [`portal.md`](portal.md).

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

| Item | Valor | Efeito |
|---|---|---|
| `v2.unidade_regional.api_token_ttl_days` | inexistente → 365 | TTL fixo (SEC-G1-14) |
| `cache.prefix` | `laravel` | padrão `page:*` limpo após escrita |
| `filesystems.media_disk` | `public` | destino das imagens |
| Throttle | `throttle.api:30,1`, `throttle:100,1` | 429 e headers |
| Auth | Bearer `asc_...` | `authenticateToken` por controller |
| Escopo | unidade do token | sem escopos por recurso |

Não há permissão RBAC na API; a autorização é a posse do token.

## 11. Observabilidade, erros e limites

- Logs: `[UnidadeRegionalNewsApi] Notícia criada|atualizada|arquivada via API`,
  `[UnidadeRegionalNewsApi] Falha ao baixar imagem de capa`, `Falha ao processar imagem inline`,
  `[UnidadeRegionalAssociadosApi] Associados sincronizados|Associado arquivado|Associado atualizado`.
- Auditoria: `news_api_create`, `news_api_update`, `unidade_regional_noticia_archive`,
  `unidade_regional_associados_sync`, `unidade_regional_associado_update`,
  `unidade_regional_associado_archive`, `unidade_regional_associado_link_remove`.
- Segurança: token nunca é logado; `raw_token` só na resposta do CMS; download remoto com
  **SSRF** (esquema livre, redirects, TLS desligado, sem bloqueio de IP interno e sem limite de
  bytes) — ver §3.6; vínculo de associado sem verificação de origem (§3.6.2).
- Limite de payload não é imposto pelo Laravel além do `post_max_size` do PHP.

## 12. Seeds e dados iniciais

Não se aplica: a API depende de uma unidade com token gerado manualmente no CMS. Nenhum token é
semeado.

## 13. Lacunas

| ID | Lacuna | Onde procurar | Impacto |
|---|---|---|---|
| LG-UNIDADEREGIONAL-A01 | Não há teste cobrindo `published_at` sem publicação | `tests/Feature/.../UnidadeRegionalNewsApiControllerTest.php` | dúvida se `published_at` deveria ser ignorado |
| LG-UNIDADEREGIONAL-A02 | Não está definido se `category` deve sair do payload de associados | OpenAPI + migration de drop | contrato com campo morto |
| LG-UNIDADEREGIONAL-A03 | Não há limite de `per_page`; não se sabe o comportamento esperado em produção | controller | payloads grandes |
| LG-UNIDADEREGIONAL-A04 | Não há escopo/ability nem rotação automática de token | model/rotas | segurança operacional |
| LG-UNIDADEREGIONAL-A05 | O wiki `wiki/api/unidade-regional.md` não é citado no `MODULE.md` como fonte de verdade | `wiki/api/unidade-regional.md` | divergência de documentação |
| LG-UNIDADEREGIONAL-A06 | Não se sabe se clientes usam os `X-RateLimit-*` (headers só nas rotas com throttle.api) | `ThrottleApiToken.php:49-55` | contrato parcial |
| LG-UNIDADEREGIONAL-A07 | Não há teste nem configuração de egress que impeça SSRF; não se sabe se o ambiente tem `allow_url_fopen`/wrappers habilitados | `NewsApiController.php:485-505`; `php.ini` | superfície varia por ambiente |
| LG-UNIDADEREGIONAL-A08 | Não há regra de negócio documentada sobre “dono” de associado nacional; a origem só existe em `metadata.source_unidade_regional_id` | `EmpresaApiController.php:426-447`; model Associados | impossível distinguir reivindicação legítima de abuso |

## 14. Checklist de paridade

- [ ] `PC-UNIDADEREGIONAL-A01` — token gerado tem 64 chars (`asc_` + 60) e prefixo de 12 (`Models/UnidadeRegional.php:279-282`).
- [ ] `PC-UNIDADEREGIONAL-A02` — expiração = `now()->addDays(365)` (`:283-285`).
- [ ] `PC-UNIDADEREGIONAL-A03` — hash sha256 confere com `hash('sha256', $raw)` (`:281`).
- [ ] `PC-UNIDADEREGIONAL-A04` — requisição sem token → `401 invalid_token` (`Controllers/UnidadeRegionalNewsApiController.php:447-450`).
- [ ] `PC-UNIDADEREGIONAL-A05` — token expirado → `401 token_expired` (`Models/UnidadeRegional.php:255-264`).
- [ ] `PC-UNIDADEREGIONAL-A06` — requisição válida atualiza `last_used_at` (`NewsApiController.php:466-467`).
- [ ] `PC-UNIDADEREGIONAL-A07` — criar notícia → `pending_approval`, `author_id = null` (`:214-215`).
- [ ] `PC-UNIDADEREGIONAL-A08` — enviar `category_id` → ignorado (`:555-570`).
- [ ] `PC-UNIDADEREGIONAL-A09` — criar notícia sem `acronym` na unidade → `category_id` nulo (`RegionalIdentityService.php:22-31`).
- [ ] `PC-UNIDADEREGIONAL-A10` — `POST /noticias/{id}` sem `content` → `422` (`ApiNewsRequest.php:27`).
- [ ] `PC-UNIDADEREGIONAL-A11` — `GET /noticias` não retorna notícias de outra unidade (`:69`).
- [ ] `PC-UNIDADEREGIONAL-A12` — `DELETE /noticias/{id}` de outra unidade → `404` (`:152-158`).
- [ ] `PC-UNIDADEREGIONAL-A13` — `DELETE` repetido → `409 already_archived` (`:160-162`).
- [ ] `PC-UNIDADEREGIONAL-A14` — `POST /associados` com `name` cria associado com `metadata.category='regional'` (`EmpresaApiController.php:402-408`).
- [ ] `PC-UNIDADEREGIONAL-A15` — `POST /associados` com `empresa_ids` inexistente → item `success=false` e `201` (`:433-447`).
- [ ] `PC-UNIDADEREGIONAL-A16` — `POST /associados/{id}` de associado **sem qualquer vínculo** com a unidade → `403`; vínculo com `status=inactive` é aceito (`:253-260`).
- [ ] `PC-UNIDADEREGIONAL-A17` — `DELETE /associados/{id}` arquiva o associado nacional (`:194-201`).
- [ ] `PC-UNIDADEREGIONAL-A18` — 31º POST/min → `429` com `retry_after` (`ThrottleApiToken.php:35-47`).
- [ ] `PC-UNIDADEREGIONAL-A19` — `GET /openapi.json` sem token → `200` e `max-age=3600` (`UnidadeRegionalApiController.php:338-345`).
- [ ] `PC-UNIDADEREGIONAL-A20` — spec contém `openapi: 3.0.3` e `securitySchemes.bearerAuth` (`:27-49`).
- [ ] `PC-UNIDADEREGIONAL-A21` — `POST /associados/{id}` com corpo `{"tags":"x","status":"inactive"}` e nenhum campo de texto → `200 "Nenhuma alteração detectada."` e **tags/status inalterados** (`EmpresaApiController.php:295-314`).
- [ ] `PC-UNIDADEREGIONAL-A22` — `POST /noticias` com `cover_image_url=file:///etc/passwd` ou host interno (`http://127.0.0.1`) é tentado pelo servidor (sem allowlist de esquema/host nem bloqueio de IP), com TLS desligado e sem limite de bytes (`NewsApiController.php:485-505`); MIME é checado só depois (`:507-515`).
- [ ] `PC-UNIDADEREGIONAL-A23` — `POST /associados` com UUID de associado nacional de **outra** unidade, não arquivado → vínculo criado (`success=true`) sem checar `metadata.source_unidade_regional_id` (`EmpresaApiController.php:426-447`).

## 15. Evidências consultadas

- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:22-609`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalEmpresaApiController.php:18-529`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalApiController.php:25-347`
- `V2/Modules/Conexoes/UnidadeRegional/Requests/ApiNewsRequest.php:23-56`
- `V2/Modules/Conexoes/UnidadeRegional/Requests/ApiEmpresaRequest.php:23-62`
- `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:250-303`
- `V2/Modules/Conexoes/UnidadeRegional/Routes/api.php:1-38`
- `V2/Core/Middleware/ThrottleApiToken.php:22-60`
- `V2/Core/Traits/ApiResponse.php:10-35`
- `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:14-120`
- `V2/Modules/Publicacoes/Noticias/Models/News.php:87-132,391-402,439-480`
- `V2/Services/Gallery/DTOs/GalleryConfig.php:17-53`; `V2/Services/Gallery/Services/GalleryService.php:53-140`
- `V2/Modules/Publicacoes/Noticias/Services/MarkdownToTiptapConverter.php:41-85`
- `V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php:23-70,221-240`
- `V2/Modules/Conexoes/UnidadeRegional/Views/cms/docs.blade.php:60-200,283-905,930-1050`
- `V2/Modules/Conexoes/UnidadeRegional/Views/cms/edit.blade.php:160-220,383-420`
- `tests/Feature/V2/Modules/Conexoes/UnidadeRegional/UnidadeRegionalNewsApiControllerTest.php:42-400`
- `tests/Feature/V2/Modules/Conexoes/UnidadeRegional/UnidadeRegionalEmpresaApiControllerTest.php:62-438`
- `sucesu_analise/_qa/security-g1.md:202-213`
