{
  "openapi": "3.1.0",
  "info": {
    "title": "Guarita API",
    "version": "1.20260902",
    "summary": "Dispare rondas de segurança, leia relatórios e receba webhooks assinados.",
    "description": "API pública da Guarita. Autenticada por chave (`gsk_…`) no header `Authorization: Bearer`, disponível nos planos Pro e Business.\n\nDocumentação completa: https://www.guarita.dev/docs/api · Changelog: https://www.guarita.dev/docs/api/changelog",
    "termsOfService": "https://www.guarita.dev/legal/terms-of-use",
    "contact": {
      "name": "Suporte Guarita",
      "email": "help@guarita.dev",
      "url": "https://www.guarita.dev/support"
    },
    "x-updated-at": "2026-09-02"
  },
  "externalDocs": {
    "description": "Documentação da API",
    "url": "https://www.guarita.dev/docs/api"
  },
  "servers": [
    {
      "url": "https://api.guarita.dev/v1/public",
      "description": "Produção"
    }
  ],
  "security": [
    {
      "apiKey": []
    }
  ],
  "tags": [
    {
      "name": "Conta",
      "description": "A chave e a conta dona dela."
    },
    {
      "name": "Apps",
      "description": "Os apps (hostnames) monitorados."
    },
    {
      "name": "Rondas",
      "description": "Histórico, disparo, estado e cancelamento de rondas."
    },
    {
      "name": "Relatório",
      "description": "O relatório completo de uma ronda, em JSON e em PDF."
    }
  ],
  "paths": {
    "/me": {
      "get": {
        "operationId": "getMe",
        "tags": [
          "Conta"
        ],
        "summary": "Confirma que a chave funciona",
        "description": "Ping autenticado: devolve a conta dona da chave, os escopos que ela carrega e o plano em vigor. É o primeiro request de toda integração nova — e o primeiro a rodar quando algo para de funcionar.\n\nRepare que exige `scans:read`: uma chave criada só com `apps:read` recebe `403` aqui, mesmo sendo válida. Nesse caso, teste com `GET /apps`.",
        "security": [
          {
            "apiKey": [
              "scans:read"
            ]
          }
        ],
        "x-scope": "scans:read",
        "responses": {
          "200": {
            "description": "A chave é válida.",
            "headers": {
              "x-request-id": {
                "description": "Id da requisição — mande pro suporte ao reportar um problema.",
                "schema": {
                  "type": "string",
                  "format": "uuid"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Me"
                },
                "example": {
                  "account_id": "acc_9f2c1b7e",
                  "key_id": "key_3a8d41c6",
                  "scopes": [
                    "scans:read",
                    "scans:write",
                    "apps:read"
                  ],
                  "plan": "pro"
                }
              }
            }
          },
          "400": {
            "description": "`bad_request` — Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "bad_request": {
                    "summary": "Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
                    "value": {
                      "error": "bad_request",
                      "message": "Unexpected token i in JSON at position 2"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized` — Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "unauthorized": {
                    "summary": "Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
                    "value": {
                      "error": "unauthorized",
                      "message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
                    }
                  }
                }
              }
            },
            "headers": {
              "WWW-Authenticate": {
                "description": "Sempre `Bearer realm=\"guarita\", error=\"invalid_token\"`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` — A chave é válida, mas não tem o escopo da rota.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "summary": "A chave é válida, mas não tem o escopo da rota.",
                    "value": {
                      "error": "insufficient_scope",
                      "message": "Esta chave não tem a permissão \"scans:write\".",
                      "required": "scans:write"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "`internal` — Erro nosso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "internal": {
                    "summary": "Erro nosso.",
                    "value": {
                      "error": "internal",
                      "message": "Erro interno."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/apps": {
      "get": {
        "operationId": "listApps",
        "tags": [
          "Apps"
        ],
        "summary": "Lista os apps monitorados",
        "description": "Os apps da conta. Um app entra aqui de duas formas: cadastrado no painel (nasce com `last_scan_at: null`) ou automaticamente na primeira ronda contra o hostname — pelo painel ou por `POST /scans`. Nos dois casos ocupa uma vaga do limite do plano.\n\nA lista não é paginada: o número de apps é limitado pelo plano (Pro: 10 · Business: sem limite), então cabe numa resposta.",
        "security": [
          {
            "apiKey": [
              "apps:read"
            ]
          }
        ],
        "x-scope": "apps:read",
        "responses": {
          "200": {
            "description": "Apps da conta.",
            "headers": {
              "x-request-id": {
                "description": "Id da requisição — mande pro suporte ao reportar um problema.",
                "schema": {
                  "type": "string",
                  "format": "uuid"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AppList"
                },
                "example": {
                  "data": [
                    {
                      "hostname": "app.exemplo.com.br",
                      "created_at": "2026-05-14T09:21:44.108Z",
                      "last_scan_at": "2026-08-27T14:09:24.106Z",
                      "verified": true
                    },
                    {
                      "hostname": "staging.exemplo.com.br",
                      "created_at": "2026-07-02T18:40:02.771Z",
                      "last_scan_at": null,
                      "verified": false
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`bad_request` — Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "bad_request": {
                    "summary": "Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
                    "value": {
                      "error": "bad_request",
                      "message": "Unexpected token i in JSON at position 2"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized` — Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "unauthorized": {
                    "summary": "Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
                    "value": {
                      "error": "unauthorized",
                      "message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
                    }
                  }
                }
              }
            },
            "headers": {
              "WWW-Authenticate": {
                "description": "Sempre `Bearer realm=\"guarita\", error=\"invalid_token\"`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` — A chave é válida, mas não tem o escopo da rota.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "summary": "A chave é válida, mas não tem o escopo da rota.",
                    "value": {
                      "error": "insufficient_scope",
                      "message": "Esta chave não tem a permissão \"scans:write\".",
                      "required": "scans:write"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "`internal` — Erro nosso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "internal": {
                    "summary": "Erro nosso.",
                    "value": {
                      "error": "internal",
                      "message": "Erro interno."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/scans": {
      "get": {
        "operationId": "listScans",
        "tags": [
          "Rondas"
        ],
        "summary": "Histórico de rondas",
        "description": "As rondas da conta, da mais recente pra mais antiga, paginadas por cursor. `risk_score` + `critical_count` são o par que uma automação usa pra decidir se derruba o build; `risk_label` vai junto porque o número sozinho não é acionável.\n\nO histórico respeita a retenção do plano (Pro: 365 dias · Business: sem limite). Rondas mais antigas não estão \"na próxima página\" — não existem mais.",
        "security": [
          {
            "apiKey": [
              "scans:read"
            ]
          }
        ],
        "x-scope": "scans:read",
        "responses": {
          "200": {
            "description": "Uma página do histórico.",
            "headers": {
              "x-request-id": {
                "description": "Id da requisição — mande pro suporte ao reportar um problema.",
                "schema": {
                  "type": "string",
                  "format": "uuid"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanList"
                },
                "example": {
                  "data": [
                    {
                      "id": "scan_7b3e9a12",
                      "hostname": "app.exemplo.com.br",
                      "status": "done",
                      "created_at": "2026-08-27T14:02:56.902Z",
                      "finished_at": "2026-08-27T14:09:24.106Z",
                      "risk_score": 78,
                      "risk_label": "HIGH_RISK",
                      "critical_count": 2,
                      "blocked": false
                    },
                    {
                      "id": "scan_1c40de55",
                      "hostname": "staging.exemplo.com.br",
                      "status": "done",
                      "created_at": "2026-08-21T11:15:03.517Z",
                      "finished_at": "2026-08-21T11:19:42.204Z",
                      "risk_score": 34,
                      "risk_label": "MODERATE_RISK",
                      "critical_count": 0,
                      "blocked": false
                    }
                  ],
                  "next_before": "2026-08-21T11:15:03.517Z"
                }
              }
            }
          },
          "400": {
            "description": "`bad_request` — Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`. · `invalid_cursor` — `before` não é uma data ISO 8601.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "bad_request": {
                    "summary": "Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
                    "value": {
                      "error": "bad_request",
                      "message": "Unexpected token i in JSON at position 2"
                    }
                  },
                  "invalid_cursor": {
                    "summary": "`before` não é uma data ISO 8601.",
                    "value": {
                      "error": "invalid_cursor",
                      "message": "`before` precisa ser uma data ISO 8601 (ex.: 2026-08-29T12:00:00.000Z) — use o `next_before` que veio na página anterior."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized` — Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "unauthorized": {
                    "summary": "Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
                    "value": {
                      "error": "unauthorized",
                      "message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
                    }
                  }
                }
              }
            },
            "headers": {
              "WWW-Authenticate": {
                "description": "Sempre `Bearer realm=\"guarita\", error=\"invalid_token\"`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` — A chave é válida, mas não tem o escopo da rota.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "summary": "A chave é válida, mas não tem o escopo da rota.",
                    "value": {
                      "error": "insufficient_scope",
                      "message": "Esta chave não tem a permissão \"scans:write\".",
                      "required": "scans:write"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "`internal` — Erro nosso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "internal": {
                    "summary": "Erro nosso.",
                    "value": {
                      "error": "internal",
                      "message": "Erro interno."
                    }
                  }
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rondas por página. Teto `50` (valores acima são reduzidos a 50; ausente, inválido ou ≤ 0 cai no padrão).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 20
            }
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "description": "Cursor: só rondas criadas ANTES deste instante (exclusivo). Use o `next_before` da página anterior. Data não-ISO responde `400 invalid_cursor`.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ]
      },
      "post": {
        "operationId": "createScan",
        "tags": [
          "Rondas"
        ],
        "summary": "Dispara uma ronda",
        "description": "Enfileira uma ronda e responde `202` na hora com o `id`. A ronda não é instantânea (busca o app, roda dezenas de verificações e, com IA, ainda analisa), então o resultado você busca depois em `GET /scans/{id}` — ou recebe pelo webhook `scan.completed`.\n\nSó `target` é obrigatório: sem mais nada, é a ronda padrão do painel (IA e infra ligadas, o resto desligado). As demais opções são as MESMAS da tela — testes autenticados, modo agressivo, LGPD e escopo extra de infra — com os mesmos padrões e as mesmas travas.\n\nCota, limite de apps, plano, verificação e autorização são decididos pelo MESMO caso de uso do painel: a chave muda quem chama, nunca o que é permitido. Ordem das travas: uso justo (`429`) → lista de bloqueio (`403`) → recurso do plano (`403 feature_locked`) → propriedade do app (`403 domain_unverified`) → confirmação intrusiva (`400`) → cota de IA (`402`) → limite de apps (`402`).",
        "security": [
          {
            "apiKey": [
              "scans:write"
            ]
          }
        ],
        "x-scope": "scans:write",
        "responses": {
          "202": {
            "description": "Ronda aceita e enfileirada.",
            "headers": {
              "x-request-id": {
                "description": "Id da requisição — mande pro suporte ao reportar um problema.",
                "schema": {
                  "type": "string",
                  "format": "uuid"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanCreated"
                },
                "example": {
                  "id": "scan_e11a03d9",
                  "status": "running"
                }
              }
            }
          },
          "400": {
            "description": "`bad_request` — Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`. · `invalid_target` — `target` ausente ou não é URL completa (`http://` ou `https://`). · `intrusive_unauthorized` — `aggressive: true` sem `authorized_intrusive: true`. O modo agressivo mexe no app de verdade, e a confirmação é por ronda — a declaração da chave não basta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "bad_request": {
                    "summary": "Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
                    "value": {
                      "error": "bad_request",
                      "message": "Unexpected token i in JSON at position 2"
                    }
                  },
                  "invalid_target": {
                    "summary": "`target` ausente ou não é URL completa (`http://` ou `https://`).",
                    "value": {
                      "error": "invalid_target",
                      "message": "Informe `target` como URL completa, começando com https://"
                    }
                  },
                  "intrusive_unauthorized": {
                    "summary": "`aggressive: true` sem `authorized_intrusive: true`. O modo agressivo mexe no app de verdade, e a confirmação é por ronda — a declaração da chave não basta.",
                    "value": {
                      "error": "intrusive_unauthorized",
                      "message": "O modo agressivo executa testes intrusivos que mutam estado no alvo. Confirme que você tem autorização explícita para testá-lo dessa forma."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized` — Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "unauthorized": {
                    "summary": "Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
                    "value": {
                      "error": "unauthorized",
                      "message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
                    }
                  }
                }
              }
            },
            "headers": {
              "WWW-Authenticate": {
                "description": "Sempre `Bearer realm=\"guarita\", error=\"invalid_token\"`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "402": {
            "description": "`quota_exceeded` — Cota de análises com IA do mês esgotada (só com `ai: true`). · `domain_limit` — Limite de apps do plano atingido ao escanear um hostname NOVO.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "quota_exceeded": {
                    "summary": "Cota de análises com IA do mês esgotada (só com `ai: true`).",
                    "value": {
                      "error": "quota_exceeded",
                      "message": "Você usou suas 20 análises com IA do mês. Faça upgrade pra liberar mais.",
                      "plan": "pro",
                      "limit": 20,
                      "used": 20,
                      "overagePrice": 15,
                      "upgradeTo": "business"
                    }
                  },
                  "domain_limit": {
                    "summary": "Limite de apps do plano atingido ao escanear um hostname NOVO.",
                    "value": {
                      "error": "domain_limit",
                      "message": "Seu plano cobre 10 apps. Remova um app em Apps ou faça upgrade pra escanear outro.",
                      "limit": 10,
                      "used": 10,
                      "upgradeTo": "business"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` — A chave é válida, mas não tem o escopo da rota. · `feature_locked` — A ronda pediu um recurso que o plano da conta não inclui: `authenticated` ou `aggressive` (`authenticatedScan`) ou `lgpd` (`lgpdScan`). · `target_blocked` — Alvo na lista de bloqueio (órgão público, banco, grande plataforma) ou identificador interno de integração. Vale mesmo com a declaração de autorização da chave. · `domain_unverified` — `ai: true`, `authenticated: true` ou `aggressive: true` num app sem prova de propriedade VIGENTE — nunca verificado, ou atestação de provedor vencida/revogada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "summary": "A chave é válida, mas não tem o escopo da rota.",
                    "value": {
                      "error": "insufficient_scope",
                      "message": "Esta chave não tem a permissão \"scans:write\".",
                      "required": "scans:write"
                    }
                  },
                  "feature_locked": {
                    "summary": "A ronda pediu um recurso que o plano da conta não inclui: `authenticated` ou `aggressive` (`authenticatedScan`) ou `lgpd` (`lgpdScan`).",
                    "value": {
                      "error": "feature_locked",
                      "message": "Seu plano não inclui o modo agressivo.",
                      "feature": "authenticatedScan",
                      "upgradeTo": "pro"
                    }
                  },
                  "target_blocked": {
                    "summary": "Alvo na lista de bloqueio (órgão público, banco, grande plataforma) ou identificador interno de integração. Vale mesmo com a declaração de autorização da chave.",
                    "value": {
                      "error": "target_blocked",
                      "message": "Este alvo está na lista de bloqueio da Guarita — escaneie só apps que são seus."
                    }
                  },
                  "domain_unverified": {
                    "summary": "`ai: true`, `authenticated: true` ou `aggressive: true` num app sem prova de propriedade VIGENTE — nunca verificado, ou atestação de provedor vencida/revogada.",
                    "value": {
                      "error": "domain_unverified",
                      "message": "Testes intrusivos ou autenticados exigem comprovar que app.exemplo.com.br é seu. Verifique a propriedade do app em Apps e tente de novo.",
                      "hostname": "app.exemplo.com.br"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "`domain_provider_conflict` — O hostname é de um projeto gerenciado por uma integração (Vercel ou Lovable).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "domain_provider_conflict": {
                    "summary": "O hostname é de um projeto gerenciado por uma integração (Vercel ou Lovable).",
                    "value": {
                      "error": "domain_provider_conflict",
                      "message": "Este endereço já é gerenciado pela Vercel. Abra o app integrado em Apps em vez de cadastrá-lo novamente por URL.",
                      "provider": "vercel"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited` — Muitas rondas em pouco tempo. O balde é por conta: 10 rondas de rajada, reabastecendo 1 por minuto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "rate_limited": {
                    "summary": "Muitas rondas em pouco tempo. O balde é por conta: 10 rondas de rajada, reabastecendo 1 por minuto.",
                    "value": {
                      "error": "rate_limited",
                      "message": "Muitas rondas em pouco tempo. Aguarde ~42s e tente de novo — o limite é de uso justo, pra manter a ronda básica grátis pra todo mundo.",
                      "retryAfterSec": 42
                    }
                  }
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Segundos até a próxima ronda caber.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "description": "`internal` — Erro nosso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "internal": {
                    "summary": "Erro nosso.",
                    "value": {
                      "error": "internal",
                      "message": "Erro interno."
                    }
                  }
                }
              }
            }
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScanCreateRequest"
              },
              "example": {
                "target": "https://app.exemplo.com.br",
                "ai": true
              }
            }
          }
        }
      }
    },
    "/scans/{id}": {
      "get": {
        "operationId": "getScan",
        "tags": [
          "Rondas"
        ],
        "summary": "Estado e resumo de uma ronda",
        "description": "O endpoint de polling depois de `POST /scans`. Enquanto a ronda roda, `progress` diz onde ela está (fase, verificações feitas/previstas, verificação atual) e `summary` é `null`; quando `status` vira `done`, `progress` vira `null` e `summary` traz score, faixa e contagem de críticos.\n\nUm id de outra conta responde `404`, igual a um id inexistente — a API não confirma a existência de rondas de terceiros.",
        "security": [
          {
            "apiKey": [
              "scans:read"
            ]
          }
        ],
        "x-scope": "scans:read",
        "responses": {
          "200": {
            "description": "A ronda.",
            "headers": {
              "x-request-id": {
                "description": "Id da requisição — mande pro suporte ao reportar um problema.",
                "schema": {
                  "type": "string",
                  "format": "uuid"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Scan"
                },
                "example": {
                  "id": "scan_7b3e9a12",
                  "hostname": "app.exemplo.com.br",
                  "target": "https://app.exemplo.com.br",
                  "status": "done",
                  "created_at": "2026-08-27T14:02:56.902Z",
                  "finished_at": "2026-08-27T14:09:24.106Z",
                  "progress": null,
                  "summary": {
                    "id": "scan_7b3e9a12",
                    "target": "https://app.exemplo.com.br",
                    "hostname": "app.exemplo.com.br",
                    "status": "done",
                    "score": 78,
                    "riskLabel": "HIGH_RISK",
                    "criticalCount": 2,
                    "finishedAt": "2026-08-27T14:09:24.106Z",
                    "durationMs": 387096,
                    "blocked": false
                  }
                }
              }
            }
          },
          "400": {
            "description": "`bad_request` — Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "bad_request": {
                    "summary": "Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
                    "value": {
                      "error": "bad_request",
                      "message": "Unexpected token i in JSON at position 2"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized` — Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "unauthorized": {
                    "summary": "Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
                    "value": {
                      "error": "unauthorized",
                      "message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
                    }
                  }
                }
              }
            },
            "headers": {
              "WWW-Authenticate": {
                "description": "Sempre `Bearer realm=\"guarita\", error=\"invalid_token\"`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` — A chave é válida, mas não tem o escopo da rota.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "summary": "A chave é válida, mas não tem o escopo da rota.",
                    "value": {
                      "error": "insufficient_scope",
                      "message": "Esta chave não tem a permissão \"scans:write\".",
                      "required": "scans:write"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — Ronda inexistente, de outra conta, ou ainda sem relatório.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "not_found": {
                    "summary": "Ronda inexistente, de outra conta, ou ainda sem relatório.",
                    "value": {
                      "error": "not_found",
                      "message": "Ronda não encontrada."
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "`internal` — Erro nosso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "internal": {
                    "summary": "Erro nosso.",
                    "value": {
                      "error": "internal",
                      "message": "Erro interno."
                    }
                  }
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Id da ronda (`scan_…`), devolvido por `POST /scans` ou por `GET /scans`.",
            "schema": {
              "type": "string",
              "example": "scan_7b3e9a12"
            }
          }
        ]
      }
    },
    "/scans/{id}/cancel": {
      "post": {
        "operationId": "cancelScan",
        "tags": [
          "Rondas"
        ],
        "summary": "Cancela uma ronda em andamento",
        "description": "Interrompe uma ronda que ainda está rodando (`queued`, `running` ou `analyzing`) e responde com o estado dela. É o botão \"Cancelar\" da tela de espera, pra quando o pipeline foi abortado ou a ronda saiu por engano.\n\nUm id de outra conta responde `404`, igual a um id inexistente.",
        "security": [
          {
            "apiKey": [
              "scans:write"
            ]
          }
        ],
        "x-scope": "scans:write",
        "responses": {
          "200": {
            "description": "O estado da ronda depois do pedido.",
            "headers": {
              "x-request-id": {
                "description": "Id da requisição — mande pro suporte ao reportar um problema.",
                "schema": {
                  "type": "string",
                  "format": "uuid"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanCanceled"
                },
                "example": {
                  "id": "scan_7b3e9a12",
                  "status": "canceled",
                  "finished_at": "2026-09-02T12:00:00.000Z"
                }
              }
            }
          },
          "400": {
            "description": "`bad_request` — Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "bad_request": {
                    "summary": "Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
                    "value": {
                      "error": "bad_request",
                      "message": "Unexpected token i in JSON at position 2"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized` — Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "unauthorized": {
                    "summary": "Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
                    "value": {
                      "error": "unauthorized",
                      "message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
                    }
                  }
                }
              }
            },
            "headers": {
              "WWW-Authenticate": {
                "description": "Sempre `Bearer realm=\"guarita\", error=\"invalid_token\"`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` — A chave é válida, mas não tem o escopo da rota.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "summary": "A chave é válida, mas não tem o escopo da rota.",
                    "value": {
                      "error": "insufficient_scope",
                      "message": "Esta chave não tem a permissão \"scans:write\".",
                      "required": "scans:write"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — Ronda inexistente, de outra conta, ou ainda sem relatório.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "not_found": {
                    "summary": "Ronda inexistente, de outra conta, ou ainda sem relatório.",
                    "value": {
                      "error": "not_found",
                      "message": "Ronda não encontrada."
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "`internal` — Erro nosso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "internal": {
                    "summary": "Erro nosso.",
                    "value": {
                      "error": "internal",
                      "message": "Erro interno."
                    }
                  }
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Id da ronda (`scan_…`), devolvido por `POST /scans` ou por `GET /scans`.",
            "schema": {
              "type": "string",
              "example": "scan_7b3e9a12"
            }
          }
        ]
      }
    },
    "/scans/{id}/report": {
      "get": {
        "operationId": "getReport",
        "tags": [
          "Relatório"
        ],
        "summary": "Relatório completo de uma ronda",
        "description": "Achados, evidências, camada leiga, conserto pronto, análise da IA, inventário de infra e o que o app já acerta. É o mesmo documento que o painel mostra e o PDF imprime — passa pelo mesmo preparo: o que o seu plano não destrava na tela também não sai por aqui.\n\nO relatório só existe quando a ronda produziu resultado. Antes disso (e numa ronda que falhou sem gerar nada) a resposta é `404` — estado normal de uma ronda em andamento, não erro de integração. Consulte `GET /scans/{id}` pra saber quando pedir.",
        "security": [
          {
            "apiKey": [
              "scans:read"
            ]
          }
        ],
        "x-scope": "scans:read",
        "responses": {
          "200": {
            "description": "O relatório (recortado — um relatório real tem dezenas de achados).",
            "headers": {
              "x-request-id": {
                "description": "Id da requisição — mande pro suporte ao reportar um problema.",
                "schema": {
                  "type": "string",
                  "format": "uuid"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Report"
                },
                "example": {
                  "schemaVersion": "1.0",
                  "scan": {
                    "id": "scan_7b3e9a12",
                    "target": "https://app.exemplo.com.br",
                    "hostname": "app.exemplo.com.br",
                    "stack": [
                      "Lovable",
                      "Supabase",
                      "Vercel"
                    ],
                    "status": "done",
                    "startedAt": "2026-08-27T14:02:57.010Z",
                    "finishedAt": "2026-08-27T14:09:24.106Z",
                    "durationMs": 387096,
                    "modulesRun": 37
                  },
                  "findings": [
                    {
                      "id": "SECRETS-001",
                      "module": "secrets",
                      "severity": "critical",
                      "cvss": 9.8,
                      "title": "Supabase service_role key exposta no bundle JS",
                      "description": "Chave de serviço (bypassa RLS) encontrada em index-CBWCsxTw.js. Concede acesso administrativo total à API de dados.",
                      "evidence": "eyJhbGciOiJIUzI1Ni…  ·  role: \"service_role\"  ·  fonte: app.exemplo.com.br/assets/index-CBWCsxTw.js",
                      "recommendation": "Rotacione a service_role e use apenas a anon key no client.",
                      "confidence": "firm",
                      "friendly": {
                        "headline": "Sua \"senha mestra\" do banco está exposta no site",
                        "whatItIs": "A service_role é a chave de administrador do seu Supabase. Ela está escrita no JavaScript que qualquer visitante baixa só de abrir seu site.",
                        "whyItMatters": "Com ela, qualquer pessoa pode ler, alterar ou apagar todos os dados do seu app.",
                        "urgency": "now"
                      },
                      "fix": {
                        "kind": "code",
                        "targets": [
                          "lovable",
                          "cursor",
                          "v0",
                          "bolt"
                        ],
                        "prompt": "Remova a chave service_role do Supabase de todo o código do front-end…",
                        "code": "const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)"
                      }
                    },
                    {
                      "id": "CORS-001",
                      "module": "cors",
                      "severity": "high",
                      "cvss": 7.5,
                      "title": "CORS reflete qualquer Origin com credenciais",
                      "description": "A API devolve Access-Control-Allow-Origin igual ao Origin recebido, com credentials.",
                      "evidence": "Origin: https://evil.test  →  Access-Control-Allow-Origin: https://evil.test",
                      "recommendation": "Restrinja as origens permitidas ao seu domínio.",
                      "confidence": "confirmed",
                      "fixWithheld": true
                    }
                  ],
                  "ai": {
                    "executive_summary": "O app expõe uma credencial administrativa do banco e opera com RLS desligado…",
                    "risk_score": {
                      "score": 78,
                      "label": "HIGH_RISK",
                      "justification": "Credencial privilegiada exposta + RLS desabilitado permitem takeover total do banco."
                    },
                    "risk_scenarios": [],
                    "owasp_mapping": [
                      {
                        "category": "A02:2021-Cryptographic Failures",
                        "finding_ids": [
                          "SECRETS-001"
                        ],
                        "status": "vulnerable"
                      }
                    ],
                    "remediation_plan": [
                      {
                        "priority": 1,
                        "finding_ids": [
                          "SECRETS-001"
                        ],
                        "action": "Esconder a chave de administrador do Supabase",
                        "effort": "quick_win",
                        "impact_if_not_fixed": "Acesso total ao banco por qualquer visitante."
                      }
                    ],
                    "additional_insights": [],
                    "infra_inventory": [
                      {
                        "provider": "supabase",
                        "asset": "plsvzxvydhkvlxbtxgrs.supabase.co",
                        "kind": "baas_project",
                        "exposure": "credential_leaked",
                        "notes": "service_role exposta no bundle."
                      }
                    ],
                    "cross_infra_chains": [],
                    "discovered_vulnerabilities": []
                  },
                  "infra": {
                    "assets": [],
                    "credentials": [],
                    "providers": [
                      "supabase",
                      "gcp",
                      "vercel"
                    ],
                    "scopeHosts": [
                      "app.exemplo.com.br",
                      "plsvzxvydhkvlxbtxgrs.supabase.co"
                    ],
                    "notes": []
                  },
                  "strengths": [
                    "HTTPS ativo e certificado válido",
                    "Nenhuma chave do Stripe exposta"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`bad_request` — Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "bad_request": {
                    "summary": "Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
                    "value": {
                      "error": "bad_request",
                      "message": "Unexpected token i in JSON at position 2"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized` — Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "unauthorized": {
                    "summary": "Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
                    "value": {
                      "error": "unauthorized",
                      "message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
                    }
                  }
                }
              }
            },
            "headers": {
              "WWW-Authenticate": {
                "description": "Sempre `Bearer realm=\"guarita\", error=\"invalid_token\"`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` — A chave é válida, mas não tem o escopo da rota.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "summary": "A chave é válida, mas não tem o escopo da rota.",
                    "value": {
                      "error": "insufficient_scope",
                      "message": "Esta chave não tem a permissão \"scans:write\".",
                      "required": "scans:write"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — Ronda inexistente, de outra conta, ou ainda sem relatório.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "not_found": {
                    "summary": "Ronda inexistente, de outra conta, ou ainda sem relatório.",
                    "value": {
                      "error": "not_found",
                      "message": "Ronda não encontrada."
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "`internal` — Erro nosso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "internal": {
                    "summary": "Erro nosso.",
                    "value": {
                      "error": "internal",
                      "message": "Erro interno."
                    }
                  }
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Id da ronda (`scan_…`), devolvido por `POST /scans` ou por `GET /scans`.",
            "schema": {
              "type": "string",
              "example": "scan_7b3e9a12"
            }
          }
        ]
      }
    },
    "/scans/{id}/report.pdf": {
      "get": {
        "operationId": "getReportPdf",
        "tags": [
          "Relatório"
        ],
        "summary": "Relatório de uma ronda em PDF",
        "description": "O mesmo documento que o botão \"Salvar PDF\" do painel gera — pra anexar no chamado, arquivar no CI ou mandar pro cliente. Passa pelo MESMO preparo e pela mesma trava de plano do relatório em JSON: o que não sai na tela não sai aqui. No Business, vem com a marca da conta.\n\nSó existe quando a ronda produziu resultado; antes disso a resposta é `404`, como em `GET /scans/{id}/report`.",
        "security": [
          {
            "apiKey": [
              "scans:read"
            ]
          }
        ],
        "x-scope": "scans:read",
        "responses": {
          "200": {
            "description": "O PDF, como anexo (`Content-Disposition: attachment`).",
            "headers": {
              "x-request-id": {
                "description": "Id da requisição — mande pro suporte ao reportar um problema.",
                "schema": {
                  "type": "string",
                  "format": "uuid"
                }
              },
              "Content-Disposition": {
                "description": "`attachment; filename=\"<prefixo>-<hostname>.pdf\"` — prefixo `guarita`, ou o slug da marca da conta (Business).",
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "description": "Sempre `no-store`.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "no-store"
                  ]
                }
              }
            },
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "`bad_request` — Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "bad_request": {
                    "summary": "Corpo malformado (JSON inválido). O status acompanha o motivo: `413` com corpo acima de 1 MB, `415` com `Content-Type` que não seja JSON — o código `error` continua `bad_request`.",
                    "value": {
                      "error": "bad_request",
                      "message": "Unexpected token i in JSON at position 2"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized` — Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "unauthorized": {
                    "summary": "Chave ausente, fora do formato `Bearer gsk_…`, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o header `WWW-Authenticate`.",
                    "value": {
                      "error": "unauthorized",
                      "message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
                    }
                  }
                }
              }
            },
            "headers": {
              "WWW-Authenticate": {
                "description": "Sempre `Bearer realm=\"guarita\", error=\"invalid_token\"`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` — A chave é válida, mas não tem o escopo da rota.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "summary": "A chave é válida, mas não tem o escopo da rota.",
                    "value": {
                      "error": "insufficient_scope",
                      "message": "Esta chave não tem a permissão \"scans:write\".",
                      "required": "scans:write"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — Ronda inexistente, de outra conta, ou ainda sem relatório.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "not_found": {
                    "summary": "Ronda inexistente, de outra conta, ou ainda sem relatório.",
                    "value": {
                      "error": "not_found",
                      "message": "Ronda não encontrada."
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "`internal` — Erro nosso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "internal": {
                    "summary": "Erro nosso.",
                    "value": {
                      "error": "internal",
                      "message": "Erro interno."
                    }
                  }
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Id da ronda (`scan_…`), devolvido por `POST /scans` ou por `GET /scans`.",
            "schema": {
              "type": "string",
              "example": "scan_7b3e9a12"
            }
          }
        ]
      }
    }
  },
  "webhooks": {
    "scan.completed": {
      "post": {
        "summary": "Uma ronda terminou",
        "description": "Toda ronda que passa pela fila e é gravada como `done` — disparada no painel, por `POST /scans` ou pelas integrações.\n\nAs rondas que rodam por dentro, sem passar pela fila, NÃO emitem este evento: o plantão agendado (que tem o `finding.opened`) e o scan-on-deploy.\n\nO relatório completo não vem no webhook. Use `scan_id` em `GET /scans/{id}/report` — assim a carga fica pequena e o conteúdo sensível sai por uma porta autenticada por você.",
        "parameters": [
          {
            "name": "x-guarita-signature",
            "in": "header",
            "required": true,
            "description": "`t=<unix>,v1=<hmac hex>` — HMAC SHA-256 de `<t>.<corpo cru>` com o segredo `gwh_…` do destino. Tolerância de 300s no timestamp.",
            "schema": {
              "type": "string",
              "example": "t=1756483200,v1=6f2a…c4"
            }
          },
          {
            "name": "x-guarita-delivery",
            "in": "header",
            "required": true,
            "description": "Id da ENTREGA (`dlv_` + UUID). Repete nas retentativas da mesma entrega; o id do evento (pra deduplicar) vai no corpo.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "User-Agent",
            "in": "header",
            "required": true,
            "description": "Sempre `Guarita-Webhook/1`. Não é autenticação — só a assinatura prova a origem.",
            "schema": {
              "type": "string",
              "enum": [
                "Guarita-Webhook/1"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "event": {
                        "type": "string",
                        "enum": [
                          "scan.completed"
                        ]
                      },
                      "data": {
                        "$ref": "#/components/schemas/ScanCompletedData"
                      }
                    }
                  }
                ]
              },
              "example": {
                "id": "evt_9f1c2a54-4c1e-4a0c-9a1f-7b6a2f0d8e33",
                "event": "scan.completed",
                "createdAt": "2026-08-29T14:22:07.481Z",
                "data": {
                  "scan_id": "scan_5e2a91c4",
                  "hostname": "app.exemplo.com.br",
                  "target": "https://app.exemplo.com.br",
                  "risk_score": 41,
                  "risk_label": "MODERATE_RISK",
                  "critical_count": 1,
                  "blocked": false,
                  "report_url": "https://www.guarita.dev/scans/scan_5e2a91c4"
                }
              }
            }
          }
        },
        "responses": {
          "429": {
            "description": "Temporário: entra na fila de retentativa."
          },
          "2XX": {
            "description": "Entrega concluída. Responda em até 10s; o corpo da resposta não é lido."
          },
          "5XX": {
            "description": "Temporário: entra na fila de retentativa."
          },
          "3XX": {
            "description": "Definitivo: redirect não é seguido. Cadastre a URL final."
          },
          "4XX": {
            "description": "Definitivo (exceto 429): a entrega é encerrada sem retentativa."
          }
        }
      }
    },
    "scan.failed": {
      "post": {
        "summary": "Uma ronda falhou",
        "description": "A ronda estourou o tempo máximo sem produzir resultado, ou a fila desistiu depois de todas as tentativas.\n\nAssine junto com `scan.completed`: só um dos dois chega pra cada ronda. Tratar só o sucesso faz \"ronda que não rodou\" virar silêncio — indistinguível de \"não encontrei nada\".",
        "parameters": [
          {
            "name": "x-guarita-signature",
            "in": "header",
            "required": true,
            "description": "`t=<unix>,v1=<hmac hex>` — HMAC SHA-256 de `<t>.<corpo cru>` com o segredo `gwh_…` do destino. Tolerância de 300s no timestamp.",
            "schema": {
              "type": "string",
              "example": "t=1756483200,v1=6f2a…c4"
            }
          },
          {
            "name": "x-guarita-delivery",
            "in": "header",
            "required": true,
            "description": "Id da ENTREGA (`dlv_` + UUID). Repete nas retentativas da mesma entrega; o id do evento (pra deduplicar) vai no corpo.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "User-Agent",
            "in": "header",
            "required": true,
            "description": "Sempre `Guarita-Webhook/1`. Não é autenticação — só a assinatura prova a origem.",
            "schema": {
              "type": "string",
              "enum": [
                "Guarita-Webhook/1"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "event": {
                        "type": "string",
                        "enum": [
                          "scan.failed"
                        ]
                      },
                      "data": {
                        "$ref": "#/components/schemas/ScanFailedData"
                      }
                    }
                  }
                ]
              },
              "example": {
                "id": "evt_0b7e4d21-8a3f-4c6e-b2d1-5f9a0c3e7d18",
                "event": "scan.failed",
                "createdAt": "2026-08-29T15:01:33.902Z",
                "data": {
                  "scan_id": "scan_c3d9e0a1",
                  "hostname": "app.exemplo.com.br",
                  "target": "https://app.exemplo.com.br",
                  "reason": "A ronda excedeu o tempo máximo e não produziu resultado."
                }
              }
            }
          }
        },
        "responses": {
          "429": {
            "description": "Temporário: entra na fila de retentativa."
          },
          "2XX": {
            "description": "Entrega concluída. Responda em até 10s; o corpo da resposta não é lido."
          },
          "5XX": {
            "description": "Temporário: entra na fila de retentativa."
          },
          "3XX": {
            "description": "Definitivo: redirect não é seguido. Cadastre a URL final."
          },
          "4XX": {
            "description": "Definitivo (exceto 429): a entrega é encerrada sem retentativa."
          }
        }
      }
    },
    "finding.opened": {
      "post": {
        "summary": "O plantão encontrou brecha nova",
        "description": "Uma ronda de plantão (agendada) abriu ao menos um achado que não existia na ronda anterior do mesmo app. Ronda limpa não gera evento.\n\nNão sai de ronda inconclusiva (bloqueada ou parcial por tempo): comparar um retrato incompleto com um completo inventaria \"achados novos\". Por isso `blocked` e `incomplete` vêm sempre `false`.\n\nNa PRIMEIRA ronda de um app não há baseline: todos os achados contam como novos e `verdict` vem `clean` (a ronda estabelece a base). Se o seu tratamento abre chamado por item, considere ignorar eventos com `previous_risk_score: null`.\n\nÉ independente do limiar de alerta por e-mail: o limiar é preferência de quem lê a caixa de entrada, não contrato de integração.",
        "parameters": [
          {
            "name": "x-guarita-signature",
            "in": "header",
            "required": true,
            "description": "`t=<unix>,v1=<hmac hex>` — HMAC SHA-256 de `<t>.<corpo cru>` com o segredo `gwh_…` do destino. Tolerância de 300s no timestamp.",
            "schema": {
              "type": "string",
              "example": "t=1756483200,v1=6f2a…c4"
            }
          },
          {
            "name": "x-guarita-delivery",
            "in": "header",
            "required": true,
            "description": "Id da ENTREGA (`dlv_` + UUID). Repete nas retentativas da mesma entrega; o id do evento (pra deduplicar) vai no corpo.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "User-Agent",
            "in": "header",
            "required": true,
            "description": "Sempre `Guarita-Webhook/1`. Não é autenticação — só a assinatura prova a origem.",
            "schema": {
              "type": "string",
              "enum": [
                "Guarita-Webhook/1"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "event": {
                        "type": "string",
                        "enum": [
                          "finding.opened"
                        ]
                      },
                      "data": {
                        "$ref": "#/components/schemas/FindingOpenedData"
                      }
                    }
                  }
                ]
              },
              "example": {
                "id": "evt_2c7d0b18-6f4a-4b7e-9c31-0a5d8e12b774",
                "event": "finding.opened",
                "createdAt": "2026-08-29T03:11:52.094Z",
                "data": {
                  "scan_id": "scan_a17b3f90",
                  "hostname": "app.exemplo.com.br",
                  "verdict": "breach",
                  "risk_score": 58,
                  "previous_risk_score": 41,
                  "opened": [
                    "Chave service_role do Supabase exposta no bundle",
                    "Content-Security-Policy ausente"
                  ],
                  "resolved": [
                    "Strict-Transport-Security (HSTS) ausente"
                  ],
                  "blocked": false,
                  "incomplete": false
                }
              }
            }
          }
        },
        "responses": {
          "429": {
            "description": "Temporário: entra na fila de retentativa."
          },
          "2XX": {
            "description": "Entrega concluída. Responda em até 10s; o corpo da resposta não é lido."
          },
          "5XX": {
            "description": "Temporário: entra na fila de retentativa."
          },
          "3XX": {
            "description": "Definitivo: redirect não é seguido. Cadastre a URL final."
          },
          "4XX": {
            "description": "Definitivo (exceto 429): a entrega é encerrada sem retentativa."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "gsk_<43 caracteres base64url>",
        "description": "Chave de API criada em Configurações → Desenvolvedores. Cada operação exige o escopo indicado em `x-scope` (`scans:read`, `scans:write` ou `apps:read`)."
      }
    },
    "schemas": {
      "Me": {
        "type": "object",
        "properties": {
          "account_id": {
            "type": "string",
            "example": "acc_9f2c1b7e",
            "description": "Id da conta dona da chave."
          },
          "key_id": {
            "type": "string",
            "example": "key_3a8d41c6",
            "description": "Id da chave — o mesmo que aparece em Configurações → Desenvolvedores. Use no log da integração pra saber qual chave está rodando sem imprimir o segredo."
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "scans:read",
                "scans:write",
                "apps:read"
              ]
            },
            "description": "Escopos da chave, fixados na criação."
          },
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "starter",
              "pro",
              "business"
            ],
            "example": "pro",
            "description": "Plano EFETIVO da conta agora — o mesmo que decide cota e recursos. Na prática vem `pro` ou `business`: sem API no plano, a chave nem autentica."
          }
        },
        "description": "Identidade da chave: a conta, a chave, o que ela pode e o plano em vigor.",
        "required": [
          "account_id",
          "key_id",
          "scopes",
          "plan"
        ],
        "example": {
          "account_id": "acc_9f2c1b7e",
          "key_id": "key_3a8d41c6",
          "scopes": [
            "scans:read",
            "scans:write",
            "apps:read"
          ],
          "plan": "pro"
        }
      },
      "App": {
        "type": "object",
        "properties": {
          "hostname": {
            "type": "string",
            "format": "hostname",
            "example": "app.exemplo.com.br",
            "description": "Hostname normalizado: minúsculo, sem `www` e sem porta."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Quando o app entrou na conta — string ISO 8601 em UTC (`2026-08-27T14:09:24.106Z`)."
          },
          "last_scan_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última ronda contra este app. `null` = nunca escaneado."
          },
          "verified": {
            "type": "boolean",
            "description": "A propriedade do domínio já foi comprovada alguma vez (DNS TXT, arquivo, meta tag ou atestação de provedor). Não reconfere a validade de uma atestação — veja `domain_unverified`."
          }
        },
        "description": "Um app (hostname) monitorado pela conta.",
        "required": [
          "hostname",
          "created_at",
          "last_scan_at",
          "verified"
        ],
        "example": {
          "hostname": "app.exemplo.com.br",
          "created_at": "2026-05-14T09:21:44.108Z",
          "last_scan_at": "2026-08-27T14:09:24.106Z",
          "verified": true
        }
      },
      "AppList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/App"
            },
            "description": "Apps da conta, na ordem de cadastro."
          }
        },
        "description": "Lista de apps. Não é paginada: o número de apps é limitado pelo plano.",
        "required": [
          "data"
        ]
      },
      "ScanListItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "scan_7b3e9a12",
            "description": "Id da ronda (`scan_…`)."
          },
          "hostname": {
            "type": "string",
            "format": "hostname",
            "description": "Alvo normalizado."
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "analyzing",
              "done",
              "failed",
              "canceled"
            ],
            "description": "Estado da ronda. Só leia os números quando for `done`."
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Quando a ronda foi criada. É o valor do cursor de paginação."
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Quando terminou. `null` enquanto roda."
          },
          "risk_score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "example": 78,
            "description": "Exposição de 0 a 100 — quanto MAIOR, pior. Vem `0` enquanto a ronda não terminou."
          },
          "risk_label": {
            "type": "string",
            "enum": [
              "SECURE",
              "LOW_RISK",
              "MODERATE_RISK",
              "HIGH_RISK",
              "CRITICAL_RISK"
            ],
            "description": "Faixa do número. Vem `SECURE` enquanto a ronda não terminou (é o resumo mínimo, não um veredito)."
          },
          "critical_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Quantos achados de severidade `critical`."
          },
          "blocked": {
            "type": "boolean",
            "description": "A ronda não conseguiu ver o app (rede bloqueada ou desafio de WAF/anti-bot). O número não representa o app: trate como inconclusivo."
          }
        },
        "description": "Uma ronda no histórico — o resumo que uma automação usa pra decidir.",
        "required": [
          "id",
          "hostname",
          "status",
          "created_at",
          "finished_at",
          "risk_score",
          "risk_label",
          "critical_count",
          "blocked"
        ]
      },
      "ScanList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ScanListItem"
            },
            "description": "As rondas desta página."
          },
          "next_before": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cursor da próxima página (o `created_at` do último item). `null` = acabou."
          }
        },
        "description": "Página do histórico de rondas, da mais recente pra mais antiga.",
        "required": [
          "data",
          "next_before"
        ]
      },
      "ScanSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Id da ronda."
          },
          "target": {
            "type": "string",
            "format": "uri",
            "description": "URL exata que foi escaneada."
          },
          "hostname": {
            "type": "string",
            "format": "hostname",
            "description": "Forma normalizada do alvo."
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "analyzing",
              "done",
              "failed",
              "canceled"
            ],
            "description": "Estado da ronda."
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Exposição de 0 a 100 (maior = pior)."
          },
          "riskLabel": {
            "type": "string",
            "enum": [
              "SECURE",
              "LOW_RISK",
              "MODERATE_RISK",
              "HIGH_RISK",
              "CRITICAL_RISK"
            ],
            "description": "Faixa do score."
          },
          "criticalCount": {
            "type": "integer",
            "minimum": 0,
            "description": "Achados `critical`."
          },
          "finishedAt": {
            "type": "string",
            "format": "date-time",
            "description": "Quando terminou."
          },
          "durationMs": {
            "type": "integer",
            "minimum": 0,
            "description": "Duração da ronda, em milissegundos."
          },
          "blocked": {
            "type": "boolean",
            "description": "A ronda não viu o app. Ausente = `false`."
          },
          "blockedReason": {
            "type": "string",
            "enum": [
              "connection",
              "challenge"
            ],
            "description": "Só com `blocked`. `connection` = não alcançamos o app a partir dos nossos servidores (problema do nosso lado; nada a liberar). `challenge` = o app respondeu com desafio ou 403 de WAF/anti-bot (aí vale liberar a Guarita no seu provedor)."
          }
        },
        "description": "Resumo derivado do relatório. Formato do scanner (`camelCase`), entregue como ele é.",
        "required": [
          "id",
          "target",
          "hostname",
          "status",
          "score",
          "riskLabel",
          "criticalCount",
          "durationMs"
        ]
      },
      "Scan": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "scan_7b3e9a12",
            "description": "Id da ronda."
          },
          "hostname": {
            "type": "string",
            "format": "hostname",
            "description": "Alvo normalizado — é por ele que a cota de apps é contada."
          },
          "target": {
            "type": "string",
            "format": "uri",
            "description": "URL exata que você mandou escanear."
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "analyzing",
              "done",
              "failed",
              "canceled"
            ],
            "description": "Estado atual."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Quando a ronda foi criada."
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Quando terminou. `null` enquanto roda."
          },
          "progress": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ScanProgress"
              },
              {
                "type": "null"
              }
            ],
            "description": "Onde a ronda está enquanto roda — a mesma leitura da tela de espera. `null` quando não está rodando (ainda na fila, terminada, falha ou cancelada)."
          },
          "summary": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ScanSummary"
              },
              {
                "type": "null"
              }
            ],
            "description": "Resumo do resultado. `null` enquanto a ronda não produziu resultado (`queued`, `running`, `analyzing`) e numa ronda `failed` que morreu antes de gerar algo."
          }
        },
        "description": "Estado de uma ronda. É o endpoint de polling depois de `POST /scans`.",
        "required": [
          "id",
          "hostname",
          "target",
          "status",
          "created_at",
          "finished_at",
          "progress",
          "summary"
        ],
        "example": {
          "id": "scan_7b3e9a12",
          "hostname": "app.exemplo.com.br",
          "target": "https://app.exemplo.com.br",
          "status": "done",
          "created_at": "2026-08-27T14:02:56.902Z",
          "finished_at": "2026-08-27T14:09:24.106Z",
          "progress": null,
          "summary": {
            "id": "scan_7b3e9a12",
            "target": "https://app.exemplo.com.br",
            "hostname": "app.exemplo.com.br",
            "status": "done",
            "score": 78,
            "riskLabel": "HIGH_RISK",
            "criticalCount": 2,
            "finishedAt": "2026-08-27T14:09:24.106Z",
            "durationMs": 387096,
            "blocked": false
          }
        }
      },
      "ScanProgress": {
        "type": "object",
        "properties": {
          "phase": {
            "type": "string",
            "enum": [
              "running",
              "analyzing"
            ],
            "example": "running",
            "description": "`running` = rodando as verificações · `analyzing` = na análise com IA."
          },
          "done": {
            "type": "integer",
            "minimum": 0,
            "example": 12,
            "description": "Verificações concluídas (fase `running`)."
          },
          "total": {
            "type": "integer",
            "minimum": 0,
            "example": 40,
            "description": "Verificações previstas nesta ronda."
          },
          "module": {
            "type": [
              "string",
              "null"
            ],
            "example": "headers",
            "description": "Verificação em andamento (`headers`, `cors`, `supabase`…). `null` quando nenhuma está em andamento."
          },
          "with_ai": {
            "type": "boolean",
            "description": "A análise com IA vem depois das verificações (define quantas etapas faltam)."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Última atualização do progresso — string ISO 8601 em UTC (`2026-08-27T14:09:24.106Z`)."
          }
        },
        "description": "Progresso de uma ronda em execução — o mesmo que a tela de espera mostra. Só existe enquanto a ronda roda.",
        "required": [
          "phase",
          "done",
          "total",
          "module",
          "with_ai",
          "updated_at"
        ],
        "example": {
          "phase": "running",
          "done": 12,
          "total": 40,
          "module": "headers",
          "with_ai": true,
          "updated_at": "2026-08-27T14:04:31.219Z"
        }
      },
      "ScanCreateRequest": {
        "type": "object",
        "properties": {
          "target": {
            "type": "string",
            "format": "uri",
            "example": "https://app.exemplo.com.br",
            "description": "URL completa do alvo, começando com `http://` ou `https://`."
          },
          "ai": {
            "type": "boolean",
            "description": "Análise com IA: explica cada achado e entrega o conserto pronto. Consome 1 análise da cota do mês e exige app verificado.",
            "default": true
          },
          "infra": {
            "type": "boolean",
            "description": "Descoberta e sondagem da infraestrutura por trás do app (Supabase, Firebase, clouds). Só leitura.",
            "default": true
          },
          "authenticated": {
            "type": "boolean",
            "description": "Testes autenticados: a Guarita cria um usuário de teste no seu app e checa, por dentro, se dá pra ver dados de outras pessoas. Exige plano com o recurso (Pro+) e app verificado. Pode disparar o e-mail de boas-vindas do seu app.",
            "default": false
          },
          "aggressive": {
            "type": "boolean",
            "description": "Modo agressivo: confirma as brechas explorando de verdade — cria, altera e apaga dados de teste (e limpa depois). Liga sozinho `authenticated` e `infra`. Exige plano com o recurso (Pro+), app verificado e `authorized_intrusive: true`. Prefira um ambiente de teste.",
            "default": false
          },
          "authorized_intrusive": {
            "type": "boolean",
            "description": "Só é lido com `aggressive: true`: a sua confirmação, POR RONDA, de que tem autorização expressa pra testes intrusivos neste alvo — os Termos (§2) exigem. Sem ela, `400 intrusive_unauthorized`.",
            "default": false
          },
          "lgpd": {
            "type": "boolean",
            "description": "Pré-diagnóstico LGPD: consentimento de cookies, rastreadores, política de privacidade, dado pessoal exposto e dados saindo do país. Exige plano com o recurso (Pro+). São sinais técnicos — não é parecer jurídico.",
            "default": false
          },
          "infra_scope": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "hostname"
            },
            "description": "Outros endereços SEUS que o app usa (`api.`, `cdn.`…), pra entrarem na sondagem de infra. Array de hostnames ou uma string separada por vírgula. Só o que é seu ou que você tem permissão de testar."
          }
        },
        "description": "Corpo de `POST /scans`. Só `target` é obrigatório; o resto são as opções da tela, com os mesmos padrões. Booleanos aceitam `true`/`false` e as strings `\"true\"`/`\"false\"`.",
        "required": [
          "target"
        ]
      },
      "ScanCreated": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "scan_e11a03d9",
            "description": "Id da ronda nova. Use em `GET /scans/{id}`."
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "analyzing",
              "done",
              "failed",
              "canceled"
            ],
            "example": "running",
            "description": "Estado inicial (`running`)."
          }
        },
        "description": "Resposta `202` de `POST /scans`: a ronda foi aceita e enfileirada.",
        "required": [
          "id",
          "status"
        ],
        "example": {
          "id": "scan_e11a03d9",
          "status": "running"
        }
      },
      "ScanCanceled": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "scan_7b3e9a12",
            "description": "Id da ronda."
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "analyzing",
              "done",
              "failed",
              "canceled"
            ],
            "example": "canceled",
            "description": "`canceled` quando a ronda foi interrompida agora. Se já tinha terminado, vem o estado final que ela tinha (`done`, `failed` ou `canceled`)."
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Quando a ronda parou."
          }
        },
        "description": "Resposta de `POST /scans/{id}/cancel`: o estado da ronda depois do pedido.",
        "required": [
          "id",
          "status",
          "finished_at"
        ],
        "example": {
          "id": "scan_7b3e9a12",
          "status": "canceled",
          "finished_at": "2026-09-02T12:00:00.000Z"
        }
      },
      "Report": {
        "type": "object",
        "properties": {
          "schemaVersion": {
            "type": "string",
            "enum": [
              "1.0"
            ],
            "description": "Versão do formato do relatório."
          },
          "scan": {
            "$ref": "#/components/schemas/ReportScan",
            "description": "Metadados da execução."
          },
          "findings": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Finding"
            },
            "description": "Os achados, do mais grave pro menos grave."
          },
          "ai": {
            "$ref": "#/components/schemas/AiAssessment",
            "description": "Análise da IA. Numa ronda básica (`ai: false`), bloqueada ou parcial, vem com o `risk_score` calculado e os demais blocos vazios."
          },
          "infra": {
            "$ref": "#/components/schemas/InfraInventory",
            "description": "Inventário de infraestrutura observado (provedores, ativos, credenciais)."
          },
          "strengths": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "O que o app já acerta — boas práticas comprovadas na ronda."
          },
          "lgpd": {
            "$ref": "#/components/schemas/LgpdReadiness",
            "description": "Pré-diagnóstico LGPD. Só quando a ronda incluiu os módulos LGPD (`lgpd: true` em `POST /scans`, ou a opção da tela)."
          }
        },
        "description": "O relatório completo de uma ronda — o mesmo documento que o painel mostra e o PDF imprime. Formato do scanner, versionado por `schemaVersion`: `camelCase` na estrutura e `snake_case` dentro de `ai`.",
        "required": [
          "schemaVersion",
          "scan",
          "findings",
          "ai",
          "infra"
        ],
        "example": {
          "schemaVersion": "1.0",
          "scan": {
            "id": "scan_7b3e9a12",
            "target": "https://app.exemplo.com.br",
            "hostname": "app.exemplo.com.br",
            "stack": [
              "Lovable",
              "Supabase",
              "Vercel"
            ],
            "status": "done",
            "startedAt": "2026-08-27T14:02:57.010Z",
            "finishedAt": "2026-08-27T14:09:24.106Z",
            "durationMs": 387096,
            "modulesRun": 37
          },
          "findings": [
            {
              "id": "SECRETS-001",
              "module": "secrets",
              "severity": "critical",
              "cvss": 9.8,
              "title": "Supabase service_role key exposta no bundle JS",
              "description": "Chave de serviço (bypassa RLS) encontrada em index-CBWCsxTw.js. Concede acesso administrativo total à API de dados.",
              "evidence": "eyJhbGciOiJIUzI1Ni…  ·  role: \"service_role\"  ·  fonte: app.exemplo.com.br/assets/index-CBWCsxTw.js",
              "recommendation": "Rotacione a service_role e use apenas a anon key no client.",
              "confidence": "firm",
              "friendly": {
                "headline": "Sua \"senha mestra\" do banco está exposta no site",
                "whatItIs": "A service_role é a chave de administrador do seu Supabase. Ela está escrita no JavaScript que qualquer visitante baixa só de abrir seu site.",
                "whyItMatters": "Com ela, qualquer pessoa pode ler, alterar ou apagar todos os dados do seu app.",
                "urgency": "now"
              },
              "fix": {
                "kind": "code",
                "targets": [
                  "lovable",
                  "cursor",
                  "v0",
                  "bolt"
                ],
                "prompt": "Remova a chave service_role do Supabase de todo o código do front-end…",
                "code": "const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)"
              }
            },
            {
              "id": "CORS-001",
              "module": "cors",
              "severity": "high",
              "cvss": 7.5,
              "title": "CORS reflete qualquer Origin com credenciais",
              "description": "A API devolve Access-Control-Allow-Origin igual ao Origin recebido, com credentials.",
              "evidence": "Origin: https://evil.test  →  Access-Control-Allow-Origin: https://evil.test",
              "recommendation": "Restrinja as origens permitidas ao seu domínio.",
              "confidence": "confirmed",
              "fixWithheld": true
            }
          ],
          "ai": {
            "executive_summary": "O app expõe uma credencial administrativa do banco e opera com RLS desligado…",
            "risk_score": {
              "score": 78,
              "label": "HIGH_RISK",
              "justification": "Credencial privilegiada exposta + RLS desabilitado permitem takeover total do banco."
            },
            "risk_scenarios": [],
            "owasp_mapping": [
              {
                "category": "A02:2021-Cryptographic Failures",
                "finding_ids": [
                  "SECRETS-001"
                ],
                "status": "vulnerable"
              }
            ],
            "remediation_plan": [
              {
                "priority": 1,
                "finding_ids": [
                  "SECRETS-001"
                ],
                "action": "Esconder a chave de administrador do Supabase",
                "effort": "quick_win",
                "impact_if_not_fixed": "Acesso total ao banco por qualquer visitante."
              }
            ],
            "additional_insights": [],
            "infra_inventory": [
              {
                "provider": "supabase",
                "asset": "plsvzxvydhkvlxbtxgrs.supabase.co",
                "kind": "baas_project",
                "exposure": "credential_leaked",
                "notes": "service_role exposta no bundle."
              }
            ],
            "cross_infra_chains": [],
            "discovered_vulnerabilities": []
          },
          "infra": {
            "assets": [],
            "credentials": [],
            "providers": [
              "supabase",
              "gcp",
              "vercel"
            ],
            "scopeHosts": [
              "app.exemplo.com.br",
              "plsvzxvydhkvlxbtxgrs.supabase.co"
            ],
            "notes": []
          },
          "strengths": [
            "HTTPS ativo e certificado válido",
            "Nenhuma chave do Stripe exposta"
          ]
        }
      },
      "ReportScan": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Id da ronda."
          },
          "target": {
            "type": "string",
            "format": "uri",
            "description": "URL escaneada."
          },
          "hostname": {
            "type": "string",
            "format": "hostname",
            "description": "Alvo normalizado."
          },
          "stack": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Stack detectada (ex.: `[\"Lovable\", \"Supabase\", \"Vercel\"]`)."
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "analyzing",
              "done",
              "failed",
              "canceled"
            ],
            "description": "Estado."
          },
          "startedAt": {
            "type": "string",
            "format": "date-time",
            "description": "Início da execução."
          },
          "finishedAt": {
            "type": "string",
            "format": "date-time",
            "description": "Fim da execução."
          },
          "durationMs": {
            "type": "integer",
            "description": "Duração em milissegundos."
          },
          "modulesRun": {
            "type": "integer",
            "description": "Quantas verificações rodaram (descontando as puladas)."
          },
          "passive": {
            "type": "boolean",
            "description": "Ronda PASSIVA: só os checks observacionais, porque o domínio não estava verificado (ou excedia a cota de apps verificados do plano)."
          },
          "blocked": {
            "type": "boolean",
            "description": "A ronda não conseguiu ver o app. A IA é pulada e a cota NÃO é consumida."
          },
          "blockedReason": {
            "type": "string",
            "enum": [
              "connection",
              "challenge"
            ],
            "description": "Motivo do bloqueio (só com `blocked`): `connection` ou `challenge`."
          },
          "incomplete": {
            "type": "boolean",
            "description": "O tempo esgotou e a ronda terminou PARCIAL (alguns módulos não rodaram). A IA é pulada e a cota não é consumida."
          }
        },
        "description": "Metadados da execução da ronda.",
        "required": [
          "id",
          "target",
          "hostname",
          "status",
          "durationMs",
          "modulesRun"
        ]
      },
      "Finding": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "SECRETS-001",
            "description": "Id do achado DENTRO desta ronda. Renumera entre rondas — pra casar achados entre rondas use `module` + `title`."
          },
          "module": {
            "type": "string",
            "example": "secrets",
            "description": "Verificação que produziu o achado (`secrets`, `cors`, `headers`, `supabase`…)."
          },
          "title": {
            "type": "string",
            "description": "Título técnico. Estável entre rondas (é a chave de identidade do achado)."
          },
          "severity": {
            "type": "string",
            "enum": [
              "critical",
              "high",
              "medium",
              "low",
              "info"
            ],
            "description": "Gravidade."
          },
          "cvss": {
            "type": "number",
            "minimum": 0,
            "maximum": 10,
            "description": "Pontuação CVSS."
          },
          "description": {
            "type": "string",
            "description": "O que foi encontrado, em detalhe."
          },
          "evidence": {
            "type": "string",
            "description": "A prova observada: requisição, resposta, trecho do bundle. Valores sensíveis vêm encurtados com `…` — a ronda não guarda o segredo inteiro."
          },
          "recommendation": {
            "type": "string",
            "description": "Recomendação curta do scanner (não é o conserto pronto)."
          },
          "confidence": {
            "type": "string",
            "enum": [
              "confirmed",
              "firm",
              "tentative"
            ],
            "description": "`confirmed` = provado por observação ativa · `firm` = detecção determinística · `tentative` = heurística que pede confirmação. Ausente = `firm`."
          },
          "good": {
            "type": "boolean",
            "description": "Achado POSITIVO: uma boa prática comprovada, não um problema."
          },
          "links": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ids de achados relacionados — hoje, os elos de uma cadeia de ataque (`module: \"chain\"`)."
          },
          "friendly": {
            "$ref": "#/components/schemas/FindingFriendly",
            "description": "A versão em português de gente."
          },
          "fix": {
            "$ref": "#/components/schemas/FindingFix",
            "description": "O conserto pronto. Ausente quando retido pelo plano — aí vem `fixWithheld: true`."
          },
          "fixWithheld": {
            "type": "boolean",
            "description": "O conserto existe, mas não está nesta resposta (plano sem análise com IA). Diz \"foi retido\", pra não parecer que a ronda não analisou."
          },
          "fixSample": {
            "type": "boolean",
            "description": "Este é o achado-AMOSTRA da conta: o conserto veio inteiro mesmo sem plano. Uma por conta, não por ronda."
          },
          "fixFromCatalog": {
            "type": "boolean",
            "description": "O conserto veio do catálogo determinístico, não da IA (ronda básica)."
          }
        },
        "description": "Um achado da ronda, com a camada leiga e o conserto (quando o plano destrava).",
        "required": [
          "id",
          "module",
          "title",
          "severity",
          "cvss",
          "description",
          "evidence",
          "recommendation"
        ]
      },
      "FindingFriendly": {
        "type": "object",
        "properties": {
          "headline": {
            "type": "string",
            "description": "Título pra pessoa (sem jargão)."
          },
          "whatItIs": {
            "type": "string",
            "description": "O que é."
          },
          "whyItMatters": {
            "type": "string",
            "description": "Por que importa."
          },
          "urgency": {
            "type": "string",
            "enum": [
              "now",
              "soon",
              "later"
            ],
            "description": "Urgência: agora · em breve · quando der."
          }
        },
        "description": "O achado em linguagem de produto.",
        "required": [
          "headline",
          "whatItIs",
          "whyItMatters",
          "urgency"
        ]
      },
      "FindingFix": {
        "type": "object",
        "properties": {
          "prompt": {
            "type": "string",
            "description": "Texto autossuficiente: problema + onde + o que fazer + como validar. É o que se copia e cola no chat da ferramenta."
          },
          "kind": {
            "type": "string",
            "enum": [
              "code",
              "dns",
              "infra",
              "action",
              "confirm",
              "chain"
            ],
            "description": "Tipo de ação: `code` (cola numa IA de código) · `dns` (registro no painel de DNS) · `infra` (console do provedor) · `action` (ação manual) · `confirm` (como confirmar um achado `tentative` antes de mexer) · `chain` (feche qualquer elo da cadeia)."
          },
          "code": {
            "type": "string",
            "description": "Exemplo DIDÁTICO de como a correção costuma ficar. Não é o conserto do seu projeto — quem adapta é a IA, a partir do `prompt`."
          },
          "targets": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ferramentas pras quais o prompt foi escrito (`lovable`, `cursor`, `v0`, `bolt`…)."
          }
        },
        "description": "O conserto pronto pra colar numa IA de código (ou o passo manual, conforme `kind`).",
        "required": [
          "prompt"
        ]
      },
      "AiAssessment": {
        "type": "object",
        "properties": {
          "executive_summary": {
            "type": "string",
            "description": "Resumo executivo em português."
          },
          "risk_score": {
            "$ref": "#/components/schemas/RiskScore",
            "description": "Exposição consolidada (0–100 + faixa)."
          },
          "remediation_plan": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RemediationItem"
            },
            "description": "Plano de correção priorizado."
          },
          "infra_inventory": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InfraInventoryEntry"
            },
            "description": "Inventário de infra na leitura da IA."
          },
          "finding_verifications": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FindingVerification"
            },
            "description": "Achados que a IA verificou ativamente, com veredito."
          },
          "risk_scenarios": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "Cenários de ataque (formato livre)."
          },
          "owasp_mapping": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "Mapeamento OWASP (formato livre)."
          },
          "additional_insights": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "Observações extras (formato livre)."
          },
          "cross_infra_chains": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "Cadeias entre provedores (formato livre)."
          },
          "discovered_vulnerabilities": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "Vulnerabilidades descobertas pela IA (formato livre)."
          }
        },
        "description": "Análise da IA sobre a ronda. Os blocos abaixo têm formato livre (são a saída do modelo) — leia `risk_score` e `remediation_plan`, que são estáveis.",
        "required": [
          "executive_summary",
          "risk_score",
          "remediation_plan",
          "infra_inventory",
          "risk_scenarios",
          "owasp_mapping",
          "additional_insights",
          "cross_infra_chains",
          "discovered_vulnerabilities"
        ]
      },
      "RiskScore": {
        "type": "object",
        "properties": {
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "0 = tranquilo · 100 = muito exposto. Faixas: seguro (0–14) · baixo (15–39) · médio (40–69) · alto (70–100)."
          },
          "label": {
            "type": "string",
            "enum": [
              "SECURE",
              "LOW_RISK",
              "MODERATE_RISK",
              "HIGH_RISK",
              "CRITICAL_RISK"
            ],
            "description": "Faixa do score."
          },
          "justification": {
            "type": "string",
            "description": "Por que esse número."
          }
        },
        "description": "Exposição consolidada da ronda.",
        "required": [
          "score",
          "label",
          "justification"
        ]
      },
      "RemediationItem": {
        "type": "object",
        "properties": {
          "priority": {
            "type": "integer",
            "minimum": 1,
            "description": "Ordem (1 = primeiro)."
          },
          "finding_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Achados que este passo fecha."
          },
          "action": {
            "type": "string",
            "description": "O que fazer."
          },
          "effort": {
            "type": "string",
            "enum": [
              "quick_win",
              "moderate",
              "significant",
              "major_refactor"
            ],
            "description": "Esforço estimado."
          },
          "impact_if_not_fixed": {
            "type": "string",
            "description": "O que acontece se não fizer."
          },
          "scoreImpact": {
            "type": "integer",
            "description": "Quantos pontos de exposição este passo derruba."
          }
        },
        "description": "Um passo do plano de correção.",
        "required": [
          "priority",
          "finding_ids",
          "action",
          "effort",
          "impact_if_not_fixed"
        ]
      },
      "InfraInventoryEntry": {
        "type": "object",
        "properties": {
          "provider": {
            "type": "string",
            "description": "Provedor (`supabase`, `aws`, `vercel`…)."
          },
          "asset": {
            "type": "string",
            "description": "O ativo (host, bucket, função)."
          },
          "kind": {
            "type": "string",
            "description": "Tipo do ativo."
          },
          "exposure": {
            "type": "string",
            "enum": [
              "public",
              "authenticated",
              "credential_leaked",
              "internal"
            ],
            "description": "Como ele está exposto."
          },
          "notes": {
            "type": "string",
            "description": "Observações."
          }
        },
        "description": "Um ativo de infra na leitura da IA.",
        "required": [
          "provider",
          "asset",
          "kind",
          "exposure",
          "notes"
        ]
      },
      "FindingVerification": {
        "type": "object",
        "properties": {
          "ref_id": {
            "type": "string",
            "description": "Id do achado verificado."
          },
          "verdict": {
            "type": "string",
            "enum": [
              "confirmed",
              "refuted"
            ],
            "description": "`confirmed` = exploração observada · `refuted` = testado e não se sustenta (alarme falso)."
          },
          "evidence": {
            "type": "string",
            "description": "A prova observada."
          }
        },
        "description": "Veredito da IA sobre um achado que ela testou ativamente.",
        "required": [
          "ref_id",
          "verdict",
          "evidence"
        ]
      },
      "InfraInventory": {
        "type": "object",
        "properties": {
          "assets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InfraAsset"
            },
            "description": "Ativos descobertos."
          },
          "credentials": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InfraCredential"
            },
            "description": "Credenciais encontradas (valores sensíveis encurtados)."
          },
          "providers": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Provedores detectados."
          },
          "scopeHosts": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Hosts que entraram no escopo da ronda."
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Observações."
          }
        },
        "description": "Inventário de infraestrutura observado pelo scanner.",
        "required": [
          "assets",
          "credentials",
          "providers",
          "scopeHosts",
          "notes"
        ]
      },
      "InfraAsset": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Id do ativo dentro da ronda."
          },
          "provider": {
            "type": "string",
            "description": "Provedor."
          },
          "kind": {
            "type": "string",
            "enum": [
              "baas_project",
              "object_storage",
              "serverless_fn",
              "auth",
              "database",
              "api",
              "cdn_host"
            ],
            "description": "Tipo."
          },
          "endpoint": {
            "type": "string",
            "description": "Endereço do ativo."
          },
          "identifier": {
            "type": "string",
            "description": "Identificador no provedor (id do projeto, nome do bucket)."
          },
          "credentials": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InfraCredential"
            },
            "description": "Credenciais ligadas a este ativo."
          },
          "evidence": {
            "type": "string",
            "description": "Onde foi observado."
          },
          "relatedTo": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ids de ativos relacionados."
          }
        },
        "description": "Um ativo de infraestrutura (projeto BaaS, bucket, função, banco…).",
        "required": [
          "id",
          "provider",
          "kind",
          "endpoint",
          "credentials",
          "evidence",
          "relatedTo"
        ]
      },
      "InfraCredential": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "anon_key",
              "service_role",
              "publishable",
              "api_key",
              "access_key",
              "jwt",
              "connection_string",
              "presigned_url"
            ],
            "description": "Tipo da credencial."
          },
          "provider": {
            "type": "string",
            "description": "Provedor."
          },
          "value": {
            "type": "string",
            "description": "Valor observado — encurtado quando sensível."
          },
          "source": {
            "type": "string",
            "description": "Onde foi encontrada."
          },
          "sensitive": {
            "type": "boolean",
            "description": "É segredo (não deveria estar público)."
          }
        },
        "description": "Uma credencial observada.",
        "required": [
          "kind",
          "provider",
          "value",
          "source",
          "sensitive"
        ]
      },
      "LgpdReadiness": {
        "type": "object",
        "properties": {
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Prontidão indicativa (maior = menos indícios de não-conformidade)."
          },
          "label": {
            "type": "string",
            "description": "Faixa da prontidão."
          },
          "categories": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "Pontuação por categoria (transparência, consentimento, segurança…)."
          },
          "findingCount": {
            "type": "integer",
            "description": "Quantos achados têm relação com a LGPD."
          },
          "disclaimer": {
            "type": "string",
            "description": "O aviso obrigatório: pré-diagnóstico automatizado, não substitui revisão por encarregado/advogado."
          }
        },
        "description": "Pré-diagnóstico técnico de indicadores LGPD. NÃO é parecer jurídico nem atesta conformidade.",
        "required": [
          "score",
          "label",
          "categories",
          "findingCount",
          "disclaimer"
        ]
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "bad_request",
              "invalid_target",
              "intrusive_unauthorized",
              "invalid_cursor",
              "unauthorized",
              "quota_exceeded",
              "domain_limit",
              "insufficient_scope",
              "feature_locked",
              "target_blocked",
              "domain_unverified",
              "not_found",
              "domain_provider_conflict",
              "rate_limited",
              "internal"
            ],
            "example": "unauthorized",
            "description": "Código estável, feito pro seu código ler."
          },
          "message": {
            "type": "string",
            "description": "Texto em português, feito pra uma pessoa ler no log."
          }
        },
        "description": "Todo erro tem a mesma forma. Trate pelo `error`; mostre a `message`.",
        "required": [
          "error",
          "message"
        ],
        "example": {
          "error": "unauthorized",
          "message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
        }
      },
      "WebhookEnvelope": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "evt_9f1c2a54-4c1e-4a0c-9a1f-7b6a2f0d8e33",
            "description": "Id do EVENTO (`evt_` + UUID). Estável entre reentregas — é por ele que se deduplica."
          },
          "event": {
            "type": "string",
            "enum": [
              "scan.completed",
              "scan.failed",
              "finding.opened"
            ],
            "description": "Qual evento."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "Quando o evento aconteceu — string ISO 8601 em UTC (`2026-08-27T14:09:24.106Z`)."
          },
          "data": {
            "type": "object",
            "additionalProperties": true,
            "description": "Carga específica do evento (veja cada um)."
          }
        },
        "description": "O corpo de todo `POST` de webhook.",
        "required": [
          "id",
          "event",
          "createdAt",
          "data"
        ]
      },
      "ScanCompletedData": {
        "type": "object",
        "properties": {
          "scan_id": {
            "type": "string",
            "description": "Id da ronda."
          },
          "hostname": {
            "type": "string",
            "format": "hostname",
            "description": "Alvo normalizado."
          },
          "target": {
            "type": "string",
            "format": "uri",
            "description": "URL escaneada."
          },
          "risk_score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Exposição (maior = pior)."
          },
          "risk_label": {
            "type": "string",
            "enum": [
              "SECURE",
              "LOW_RISK",
              "MODERATE_RISK",
              "HIGH_RISK",
              "CRITICAL_RISK"
            ],
            "description": "Faixa do score."
          },
          "critical_count": {
            "type": "integer",
            "description": "Achados `critical`."
          },
          "blocked": {
            "type": "boolean",
            "description": "A ronda não viu o app. Trate como inconclusivo, não como \"seguro\"."
          },
          "report_url": {
            "type": "string",
            "format": "uri",
            "description": "Link do relatório no painel."
          }
        },
        "description": "Carga (`data`) do evento `scan.completed`.",
        "required": [
          "scan_id",
          "hostname",
          "target",
          "risk_score",
          "risk_label",
          "critical_count",
          "blocked"
        ],
        "example": {
          "scan_id": "scan_5e2a91c4",
          "hostname": "app.exemplo.com.br",
          "target": "https://app.exemplo.com.br",
          "risk_score": 41,
          "risk_label": "MODERATE_RISK",
          "critical_count": 1,
          "blocked": false,
          "report_url": "https://www.guarita.dev/scans/scan_5e2a91c4"
        }
      },
      "ScanFailedData": {
        "type": "object",
        "properties": {
          "scan_id": {
            "type": "string",
            "description": "Id da ronda."
          },
          "hostname": {
            "type": "string",
            "format": "hostname",
            "description": "Alvo normalizado."
          },
          "target": {
            "type": "string",
            "format": "uri",
            "description": "URL que seria escaneada."
          },
          "reason": {
            "type": "string",
            "description": "A causa, em português."
          }
        },
        "description": "Carga (`data`) do evento `scan.failed`.",
        "required": [
          "scan_id",
          "hostname",
          "target",
          "reason"
        ],
        "example": {
          "scan_id": "scan_c3d9e0a1",
          "hostname": "app.exemplo.com.br",
          "target": "https://app.exemplo.com.br",
          "reason": "A ronda excedeu o tempo máximo e não produziu resultado."
        }
      },
      "FindingOpenedData": {
        "type": "object",
        "properties": {
          "scan_id": {
            "type": "string",
            "description": "Id da ronda de plantão."
          },
          "hostname": {
            "type": "string",
            "format": "hostname",
            "description": "O app."
          },
          "verdict": {
            "type": "string",
            "enum": [
              "breach",
              "warning",
              "clean"
            ],
            "description": "`breach` = achado novo grave, ou salto de 15+ pontos · `warning` = piorou, sem gravidade · `clean` = primeira ronda (base)."
          },
          "risk_score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Exposição agora."
          },
          "previous_risk_score": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Exposição da ronda anterior. `null` quando não havia."
          },
          "opened": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "TÍTULOS dos achados novos (não ids — o título é a chave de identidade entre rondas)."
          },
          "resolved": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Títulos dos achados que sumiram desde a anterior."
          },
          "blocked": {
            "type": "boolean",
            "description": "Sempre `false` (ronda bloqueada não emite)."
          },
          "incomplete": {
            "type": "boolean",
            "description": "Sempre `false` (ronda parcial não emite)."
          }
        },
        "description": "Carga (`data`) do evento `finding.opened`.",
        "required": [
          "scan_id",
          "hostname",
          "verdict",
          "risk_score",
          "previous_risk_score",
          "opened",
          "resolved",
          "blocked",
          "incomplete"
        ],
        "example": {
          "scan_id": "scan_a17b3f90",
          "hostname": "app.exemplo.com.br",
          "verdict": "breach",
          "risk_score": 58,
          "previous_risk_score": 41,
          "opened": [
            "Chave service_role do Supabase exposta no bundle",
            "Content-Security-Policy ausente"
          ],
          "resolved": [
            "Strict-Transport-Security (HSTS) ausente"
          ],
          "blocked": false,
          "incomplete": false
        }
      },
      "Error_quota_exceeded": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "properties": {
              "plan": {
                "type": "string",
                "description": "Plano atual."
              },
              "limit": {
                "type": "integer",
                "description": "Cota do período."
              },
              "used": {
                "type": "integer",
                "description": "Quanto já foi usado."
              },
              "overagePrice": {
                "type": "number",
                "description": "R$ por análise extra (0 = sem excedente no plano)."
              },
              "upgradeTo": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Plano sugerido."
              }
            },
            "required": [
              "plan",
              "limit",
              "used",
              "overagePrice",
              "upgradeTo"
            ]
          }
        ],
        "example": {
          "error": "quota_exceeded",
          "message": "Você usou suas 20 análises com IA do mês. Faça upgrade pra liberar mais.",
          "plan": "pro",
          "limit": 20,
          "used": 20,
          "overagePrice": 15,
          "upgradeTo": "business"
        }
      },
      "Error_domain_limit": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "properties": {
              "limit": {
                "type": "integer",
                "description": "Apps que o plano cobre."
              },
              "used": {
                "type": "integer",
                "description": "Apps em uso."
              },
              "upgradeTo": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Plano sugerido."
              }
            },
            "required": [
              "limit",
              "used",
              "upgradeTo"
            ]
          }
        ],
        "example": {
          "error": "domain_limit",
          "message": "Seu plano cobre 10 apps. Remova um app em Apps ou faça upgrade pra escanear outro.",
          "limit": 10,
          "used": 10,
          "upgradeTo": "business"
        }
      },
      "Error_insufficient_scope": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "properties": {
              "required": {
                "type": "string",
                "enum": [
                  "scans:read",
                  "scans:write",
                  "apps:read"
                ],
                "description": "O escopo que falta."
              }
            },
            "required": [
              "required"
            ]
          }
        ],
        "example": {
          "error": "insufficient_scope",
          "message": "Esta chave não tem a permissão \"scans:write\".",
          "required": "scans:write"
        }
      },
      "Error_feature_locked": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "properties": {
              "feature": {
                "type": "string",
                "description": "O recurso que faltou (`authenticatedScan`, `lgpdScan`)."
              },
              "upgradeTo": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Plano sugerido. `null` quando não há plano acima."
              }
            },
            "required": [
              "feature",
              "upgradeTo"
            ]
          }
        ],
        "example": {
          "error": "feature_locked",
          "message": "Seu plano não inclui o modo agressivo.",
          "feature": "authenticatedScan",
          "upgradeTo": "pro"
        }
      },
      "Error_domain_unverified": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "properties": {
              "hostname": {
                "type": "string",
                "description": "O app em questão."
              }
            },
            "required": [
              "hostname"
            ]
          }
        ],
        "example": {
          "error": "domain_unverified",
          "message": "Testes intrusivos ou autenticados exigem comprovar que app.exemplo.com.br é seu. Verifique a propriedade do app em Apps e tente de novo.",
          "hostname": "app.exemplo.com.br"
        }
      },
      "Error_domain_provider_conflict": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "properties": {
              "provider": {
                "type": "string",
                "enum": [
                  "vercel",
                  "lovable"
                ],
                "description": "Qual integração gerencia o app."
              }
            },
            "required": [
              "provider"
            ]
          }
        ],
        "example": {
          "error": "domain_provider_conflict",
          "message": "Este endereço já é gerenciado pela Vercel. Abra o app integrado em Apps em vez de cadastrá-lo novamente por URL.",
          "provider": "vercel"
        }
      },
      "Error_rate_limited": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "properties": {
              "retryAfterSec": {
                "type": "integer",
                "description": "Segundos até a próxima ronda caber. O mesmo valor vai no header `Retry-After`."
              }
            },
            "required": [
              "retryAfterSec"
            ]
          }
        ],
        "example": {
          "error": "rate_limited",
          "message": "Muitas rondas em pouco tempo. Aguarde ~42s e tente de novo — o limite é de uso justo, pra manter a ronda básica grátis pra todo mundo.",
          "retryAfterSec": 42
        }
      }
    }
  }
}