# Convenções da API REST do AgentPack

Regras para rota nova e para quem migra rota existente. Vêm das regras BE-R1,
BE-R2, BE-R3, BE-E1 e BE-03 do guia de padrões da análise 360 (achados BA-05,
BA-10 e X4-05). Referência de implementação: `backend/src/routes/auth.ts`,
`backend/src/routes/health.ts` e `backend/src/routes/agent-keys.ts`.

Cada regra diz se é **obrigatória** (código novo não entra sem ela) ou
**recomendada**, e como conferir.

## 1. Toda rota declara o contrato em zod (obrigatória)

O `buildApp` registra os compiladores do type provider zod
(`backend/src/plugins/zod-compilers.ts`) antes de qualquer rota. A rota:

- usa `app.withTypeProvider<ZodTypeProvider>()`, e `request.body`,
  `request.query` e `request.params` já saem tipados e validados.
  `request.body as ...` e `Schema.parse(request.body)` dentro do handler são
  proibidos;
- declara `schema.params`, `schema.querystring`, `schema.body` (quando houver),
  `schema.response` (o 2xx e `"4xx": problemDocumentSchema`), `tags` (uma das
  `OPENAPI_TAGS` de `plugins/swagger.ts`), `summary` (em inglês, uma frase) e
  `operationId` (camelCase, único, verbo + recurso: `createAgentKey`);
- reusa o schema zod do domínio (`src/schemas/<dominio>.ts` ou o do service),
  o mesmo que a tool MCP usa (BE-06). Não redeclare campos.

Schema que não é zod é recusado no boot. A resposta passa pelo schema, então
campo fora dele (hash, segredo, coluna interna) nunca sai.

```ts
const app = instance.withTypeProvider<ZodTypeProvider>();

app.post(
  "/agent-keys",
  {
    schema: {
      tags: ["Agent Keys"],
      operationId: "createAgentKey",
      summary: "Issue an agent key restricted to projects, scopes and toolsets",
      body: CreateAgentKeySchema,
      response: { 201: CreatedAgentKeySchema, "4xx": problemDocumentSchema },
    },
  },
  async (request, reply) => reply.status(201).send(await agentKeyService.create(actor, request.body)),
);
```

**Conferir:** `npm test -- openapi-contract` imprime quantas operações ainda
não têm o contrato completo e quais partes faltam em cada uma. Rota nova não
pode aparecer na lista. Swagger em `/docs` e o documento em `/docs/json`
(com `SWAGGER_ENABLED=true`).

## 2. Verbos (obrigatória)

| Verbo | Uso | Corpo |
| --- | --- | --- |
| `GET` | Leitura. Nunca muda estado | — |
| `POST` | Cria (`201` + o recurso) ou executa uma ação (`POST /recurso/{id}/<verbo>`, ex. `/agent-keys/{id}/revoke`) | Obrigatório ao criar |
| `PATCH` | Atualização **parcial**: só os campos enviados mudam | Schema `.partial()` do de criação |
| `PUT` | **Substituição integral**: o corpo é o recurso inteiro; campo ausente volta ao padrão | Schema completo |
| `DELETE` | Remove. `204` sem corpo, ou `200` com o recurso quando o cliente precisa dele | — |

Um `PUT` que aceita schema `.partial()` é um `PATCH` com o nome errado. Rota
nova de atualização parcial é `PATCH`. As rotas `PUT` parciais que existem hoje
(`/api/projects/{id}`, `/api/tasks/{id}`, `/api/todos/{id}`, `/api/skills/{id}`)
ganham um `PATCH` equivalente na migração do domínio, e o `PUT` fica como alias
marcado `deprecated: true` no schema até o frontend trocar.

## 3. Caminhos (obrigatória)

- Recurso com identidade própria fica no primeiro nível: `/api/<recurso>/{id}`
  (`/api/tasks/{id}`, `/api/skills/{id}`). A listagem filtra por query
  (`GET /api/tasks?projectId=...`) ou pelo caminho do projeto, abaixo.
- Coleção que pertence a um projeto é listada e criada sob ele:
  `/api/projects/{projectId}/<recurso>`. Leitura e escrita por id sob o projeto
  (`/api/projects/{projectId}/memories/{id}`) filtram por `(projectId, id)` e
  respondem 404 se o par não bater (BE-03).
- Todo o Render fica sob `/api/render/...`. Rota nova do Render fora desse
  prefixo não entra; as antigas (`/api/designs`, `/api/library/{kind}`) migram
  na lane do Render.
- Nomes no plural e em kebab-case (`/agent-keys`), ids como `{id}` (ou
  `{<recurso>Id}` quando houver mais de um no caminho) e validados como
  `z.string().uuid()` (id malformado é 422).

## 4. Pai inexistente é 404 (obrigatória)

`GET /api/projects/{projectId}/memories` com um projeto que não existe responde
`404 NOT_FOUND`, não `200 []`. O service resolve o pai antes de listar ou criar
filhos. Referência cruzada no corpo (`taskId`, `parentId`) para recurso de
outro projeto ou inexistente é `422` (código de domínio, ex. `INVALID_TASK`).

## 5. Listas (obrigatória)

Envelope único, paginado no banco (`take`/`skip` ou cursor), nunca com
`Array.slice` depois de carregar tudo:

```json
{ "items": [], "total": 0, "page": 1, "pageSize": 50, "pages": 0 }
```

- Query `page` (a partir de 1) e `pageSize`: **padrão 50, máximo 200**.
  `pageSize` acima do máximo é `422`, não truncado em silêncio.
- Listas por cursor (feeds, histórico) usam `{ "items": [], "nextCursor": "..." | null }`
  com a query `cursor`. O cursor codifica todas as chaves do `ORDER BY`.
- Itens da lista não trazem colunas de documento (`content`, `tree`,
  `snapshot`, `payload`); o corpo vem no `GET` por id.
- Schema pronto: `ListAgentKeysQuerySchema` e `AgentKeyPageSchema` em
  `routes/agent-keys.ts` são o modelo. O `PaginationSchema` antigo de
  `lib/schemas.ts` (padrão 500, máximo 1000) sai na migração de cada domínio.

## 6. Erros (obrigatória)

Todo erro sai em `application/problem+json` (RFC 9457) pelo handler global
(`plugins/problem-details.ts`): `type`, `title`, `status`, `detail`, `code`,
`errors[]` e, por compatibilidade, `error` e `message`.

| Situação | Status | `code` |
| --- | --- | --- |
| Corpo, query ou params inválidos | 422 | `VALIDATION_ERROR` |
| Header malformado (`If-Match`) | 400 | `BAD_REQUEST` |
| Sem credencial / credencial inválida | 401 | `UNAUTHORIZED` |
| Sem permissão (escopo, papel, projeto da chave) | 403 | `FORBIDDEN` |
| Recurso ou pai inexistente | 404 | `NOT_FOUND` |
| Conflito de versão ou de unicidade | 409 | `CONFLICT` (+ `details.reason`) |
| Escrita versionada sem `If-Match`/`expectedVersion` | 428 | `PRECONDITION_REQUIRED` |
| Referência inexistente (FK) | 422 | `VALIDATION_ERROR` ou código de domínio |

Rota e service lançam subclasses de `AppError` (`NotFoundError`,
`ValidationError`, `ConflictError`, `ForbiddenError`...). `reply.status(4xx).send`
e `throw new Error` em rota ou service são proibidos: o segundo vira 500.

## 7. Escritas versionadas (obrigatória)

Recurso com `version`/`rev` aceita `If-Match` (`N`, `"N"` ou `W/"N"`) ou
`expectedVersion` no corpo; sem nenhum dos dois, 428. Versão diferente da atual
é 409 com `details.reason = "VERSION_CONFLICT"`. As leituras devolvem `ETag`.

## 8. Autenticação (obrigatória)

Toda rota exige credencial (deny-by-default). Rota pública é exceção revisada:
`config: PUBLIC_ROUTE` e `security: []` no schema (o OpenAPI mostra a rota sem
cadeado), e entra na allowlist de `tests/auth-guard.test.ts`.

## 9. Auditoria (obrigatória, automática)

Toda escrita 2xx de `/api` (POST, PUT, PATCH, DELETE) grava uma linha
append-only em `audit_events` pelo hook de `plugins/audit.ts` (SEC-10, BE-O5):
ator, cliente (`User-Agent`), `source: "rest"`, entidade, ação, id, projeto,
`requestId` e o corpo redigido (`diff`). A rota nova não precisa fazer nada,
mas:

- entidade e ação saem do padrão da rota (`/tasks/:taskId/todos` →
  `task_todo`/`create`). Rota que termina num verbo (`/recurso/:id/<verbo>`)
  precisa do verbo em `TRAILING_VERBS`, senão a linha sai como `create`;
- POST que só lê usa `config: { scope: "read" }` e fica fora da trilha; POST
  que não muda estado de domínio e não é leitura (ex.: relato de erro do
  cliente) usa `config: { audit: false }`;
- campo de segredo no corpo (senha, token, secret, authorization, headers...)
  é redigido pelo nome da chave: não invente nome que esconda um segredo.

A leitura é `GET /api/audit` (filtros de projeto, ator, origem, entidade,
ação, `requestId` e período). A retenção é `AUDIT_RETENTION_DAYS`.

## 10. Documentação (recomendada)

- `summary` diz o que a operação faz; `.describe()` nos campos explica o que
  não é óbvio (formato, unidade, quando é `null`).
- Campos de texto livre têm `.max()`.
- Mudança incompatível (campo removido, status diferente) entra no
  `CHANGELOG.md` e é avisada no PR.
