{
  "openapi": "3.1.0",
  "info": {
    "title": "API Asender",
    "version": "2026-08-28",
    "summary": "A API pública da plataforma Asender.",
    "description": "Documentação completa em https://docs.asender.net. Para agentes: o contexto inteiro em um arquivo está em https://docs.asender.net/llms-full.txt."
  },
  "servers": [
    {
      "url": "https://api.asender.net",
      "description": "Plataforma"
    },
    {
      "url": "https://auth.asender.net",
      "description": "Identidade (OIDC / OAuth 2.1)"
    },
    {
      "url": "https://api.crm.asender.net",
      "description": "CRM"
    },
    {
      "url": "https://api.pages.asender.net",
      "description": "Páginas"
    },
    {
      "url": "https://api.sites.asender.net",
      "description": "Publicação e analytics"
    }
  ],
  "tags": [
    {
      "name": "api",
      "description": "Contas, identidade e o que atravessa as ferramentas. É a API da plataforma, não de um produto."
    },
    {
      "name": "auth",
      "description": "Authorization Server. É daqui que sai o token que todas as outras APIs exigem."
    },
    {
      "name": "crm",
      "description": "Jornadas, alertas, destinos e integrações de conversão. Sistema próprio, endereço próprio."
    },
    {
      "name": "pages",
      "description": "Páginas, formulários, redirects e mídia. Sistema próprio, endereço próprio."
    },
    {
      "name": "runtime",
      "description": "Publica as páginas e recebe os eventos delas. Sistema próprio, endereço próprio."
    }
  ],
  "paths": {
    "/": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "resolve host+caminho na página publicada e devolve o HTML renderizado.",
        "description": "resolve host+caminho na página publicada e devolve o HTML renderizado.\n\nOnde é usada: rota curinga do runtime, a de maior tráfego do sistema.\n\nEfeitos: consulta a fonte; escreve a resposta. 404 quando não há página: o host pode apontar para nós sem ter página naquele caminho, e devolver erro de servidor nesse caso confundiria monitoramento com tráfego normal de quem digitou a URL errada.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pagina.Servir",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e chama o MESMO pipeline dos outros dois modos.",
        "description": "resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e chama o MESMO pipeline dos outros dois modos.\n\nOnde é usada: POST no caminho da própria página.\n\nEfeitos: os do pipeline (banco, outbox). O tenant e o form vêm da PÁGINA resolvida no servidor, nunca do corpo: o form da página é HTML público, e qualquer um pode reenviá-lo com outro `form_id`. Resolver pelo host+caminho é o que impede postar no formulário de outro tenant.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pagina.Submeter",
        "x-asender-fonte": "codigo"
      }
    },
    "/healthz": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "responde 200 enquanto o processo está vivo. Não toca dependência: um liveness que consulta o banco derruba o pod quando o banco oscila.",
        "description": "responde 200 enquanto o processo está vivo. Não toca dependência: um liveness que consulta o banco derruba o pod quando o banco oscila.\n\nOnde é usada: rota pública /healthz.\n\nEfeitos: escreve JSON na resposta.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "h.Healthz",
        "x-asender-fonte": "codigo"
      }
    },
    "/version": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "nome do serviço, versão e o commit que gerou o binário.",
        "description": "nome do serviço, versão e o commit que gerou o binário.\n\nOnde é usada: rota pública, usada por ops e por quem investiga incidente.\n\nEfeitos: escreve JSON na resposta. # Por que ela é PÚBLICA, e o que ela não conta A primeira pergunta de todo incidente é \"que versão está no ar?\" — e ela tem de ser respondível sem credencial, porque quem investiga muitas vezes ainda não tem uma. O que sai é o que já está no `docker inspect` de quem tem acesso ao host: nome, versão e commit. Nunca configuração, nunca dependência, nunca endereço interno — isso é topologia, e topologia é do console de plataforma. A versão vem de LDFLAGS no build, e o default é `dev`: um binário sem carimbo diz `dev` em vez de mentir uma versão que ninguém emitiu.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "h.Versao",
        "x-asender-fonte": "codigo"
      }
    },
    "/readyz": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "503 `starting` antes do fim do boot; depois disso, CONSULTA as sondas e responde 200 `ready` ou 503 `degraded` com o nome de cada dependência.",
        "description": "503 `starting` antes do fim do boot; depois disso, CONSULTA as sondas e responde 200 `ready` ou 503 `degraded` com o nome de cada dependência.\n\nOnde é usada: rota pública /readyz.\n\nEfeitos: escreve JSON na resposta; pode tocar a dependência (com cache de 1s). # Por que ele não é mais um trinco de boot A readiness era `MarkReady()` uma vez e pronto — memória do boot, não estado atual. Num incidente real de 2026-08-27 o serviço voltou com a credencial errada do Postgres, falhou ~30 vezes por minuto ao drenar o outbox, e /healthz e /readyz responderam 200 o tempo todo: o gate de saúde do deploy passou e quem descobriu foi o pentest, depois. Readiness que só lembra do boot não enxerga a dependência que caiu DEPOIS dele. /healthz continua sem tocar em nada, e isso é de propósito: liveness que consulta o banco derruba o processo quando o banco oscila. Quem tem de dizer \"não me mande tráfego\" é o readiness. O que sai na resposta é o NOME da dependência e \"indisponível\" — nunca a mensagem do driver, que carrega usuário, host e base. Rota pública não conta topologia; o erro inteiro vai para o log.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "h.Readyz",
        "x-asender-fonte": "codigo"
      }
    },
    "/metrics": {
      "get": {
        "operationId": "AuthMetrics",
        "x-asender-authz": "public",
        "tags": [
          "meta"
        ],
        "summary": "Métricas Prometheus.",
        "x-asender-divergence": "STUB. Só um comentário. Sem OpenTelemetry.",
        "security": [],
        "responses": {
          "200": {
            "description": "Texto.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.tel.MetricsHandler",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/emails/send": {
      "post": {
        "operationId": "PublicSendEmail",
        "x-asender-authz": "apikey",
        "tags": [
          "public-email"
        ],
        "summary": "Enfileira um email (payload compatível com SES SendEmail).",
        "description": "Persiste no `asender_messages` (mensagem + outbox no MESMO commit) e o\npublisher de outbox entrega ao NATS. **Não publica direto no broker** —\nseria dual-write.\n\nCc e Bcc entram como destinatários independentes: o serviço materializa\n**uma mensagem por destinatário**, que é o comportamento correto de\ncópia oculta.\n",
        "security": [
          {
            "ApiKeyBearer": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendEmailRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "$ref": "#/components/responses/Enqueued"
          },
          "400": {
            "$ref": "#/components/responses/ValidationDetails"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TenantSuspended"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/Throttled"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "emailH.Send",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/emails/batch": {
      "post": {
        "operationId": "PublicBatchEmail",
        "x-asender-authz": "apikey",
        "tags": [
          "public-email"
        ],
        "summary": "Até 500 emails independentes numa chamada.",
        "description": "**Resultado parcial é legítimo e explícito.** Falha de um item não\nderruba os outros: cada entrada de `Results` traz `MessageId` ou\n`Error`, sempre com o `Index` de origem.\n",
        "security": [
          {
            "ApiKeyBearer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "Messages"
                ],
                "properties": {
                  "Messages": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 500,
                    "items": {
                      "$ref": "#/components/schemas/SendEmailRequest"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Lote processado (com ou sem itens rejeitados).",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Count": {
                          "type": "integer"
                        },
                        "Results": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/BatchResult"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TenantSuspended"
          },
          "429": {
            "$ref": "#/components/responses/Throttled"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "emailH.Batch",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/emails": {
      "get": {
        "operationId": "PublicListEmails",
        "x-asender-authz": "apikey",
        "tags": [
          "public-email"
        ],
        "summary": "Histórico de emails do tenant da API key.",
        "description": "`channel` é FIXO em `email` nesta rota — mandar `?channel=sms` é **422**\n(parâmetro fora da allowlist), não filtro silencioso.\n",
        "security": [
          {
            "ApiKeyBearer": []
          }
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/MessageStatus"
            }
          },
          {
            "name": "q",
            "in": "query",
            "description": "Busca. `%` e `_` são escapados no repositório.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Página de mensagens.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessagePage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TenantSuspended"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/Throttled"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "emailH.List",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/emails/{id}": {
      "get": {
        "operationId": "PublicGetEmail",
        "x-asender-authz": "apikey",
        "tags": [
          "public-email"
        ],
        "summary": "Um email com corpo, metadata e timeline de eventos.",
        "description": "Mensagem de outro tenant → **404** (não confirma existência). Mensagem\ndeste tenant em outro canal também → 404: nesta rota só email existe.\n",
        "security": [
          {
            "ApiKeyBearer": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/MessageId"
          }
        ],
        "responses": {
          "200": {
            "description": "Detalhe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageDetail"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TenantSuspended"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "429": {
            "$ref": "#/components/responses/Throttled"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "emailH.Get",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/sms/send": {
      "post": {
        "operationId": "PublicSendSMS",
        "x-asender-authz": "apikey",
        "tags": [
          "public-sms"
        ],
        "summary": "Enfileira um SMS.",
        "security": [
          {
            "ApiKeyBearer": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "To",
                  "Body"
                ],
                "properties": {
                  "From": {
                    "type": "string",
                    "description": "E.164, opcional."
                  },
                  "To": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string",
                      "description": "E.164"
                    }
                  },
                  "Body": {
                    "type": "string",
                    "maxLength": 1600
                  },
                  "IdempotencyKey": {
                    "type": "string"
                  },
                  "Tags": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "$ref": "#/components/responses/Enqueued"
          },
          "400": {
            "$ref": "#/components/responses/ValidationDetails"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TenantSuspended"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/Throttled"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "description": "valida o payload SES-shaped e persiste o envio no asender_messages, que enfileira para o worker de email.\n\nOnde é usada: API pública do cliente (autenticada por API key). Fluxo do dado: API key → APIKeyAuth → Principal.TenantID → POST /v1/messages (header X-Asender-Tenant) → messages + outbox no mesmo commit → NATS → worker.\n\nSaídas: 202 com `{MessageId, MessageIds, Status, ReusedIdempotency}`; 400 em validação local; 422 quando o asender_messages recusa; 502 se ele está fora.\n\nEfeitos: escrita no serviço de mensageria. Idempotente por IdempotencyKey.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "smsH.Send",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/push/send": {
      "post": {
        "operationId": "PublicSendPush",
        "x-asender-authz": "apikey",
        "tags": [
          "public-push"
        ],
        "summary": "Enfileira um push para tokens ou para um tópico.",
        "description": "Envio só por tópico entra como destinatário sintético `topic:<nome>` —\no `asender_messages` exige ao menos um destinatário e não modela tópico.\n",
        "security": [
          {
            "ApiKeyBearer": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "Notification"
                ],
                "properties": {
                  "DeviceTokens": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "Topic": {
                    "type": "string"
                  },
                  "Notification": {
                    "type": "object",
                    "required": [
                      "Title",
                      "Body"
                    ],
                    "properties": {
                      "Title": {
                        "type": "string"
                      },
                      "Body": {
                        "type": "string"
                      }
                    }
                  },
                  "Data": {
                    "type": "object",
                    "additionalProperties": true
                  },
                  "IdempotencyKey": {
                    "type": "string"
                  },
                  "Tags": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "$ref": "#/components/responses/Enqueued"
          },
          "400": {
            "$ref": "#/components/responses/ValidationDetails"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TenantSuspended"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/Throttled"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "pushH.Send",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/push/devices": {
      "post": {
        "operationId": "PublicRegisterDevice",
        "x-asender-authz": "apikey",
        "tags": [
          "public-push"
        ],
        "summary": "Registra (upsert por token) um device de push.",
        "description": "Idempotente pelo par (tenant, token). Responde **200** sempre, mesmo\nquando o `asender_messages` respondeu 201 no primeiro registro.\n",
        "security": [
          {
            "ApiKeyBearer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "Token",
                  "Platform"
                ],
                "properties": {
                  "Token": {
                    "type": "string"
                  },
                  "Platform": {
                    "type": "string",
                    "enum": [
                      "ios",
                      "android",
                      "web"
                    ]
                  },
                  "UserRef": {
                    "type": "string",
                    "description": "ACEITO E NÃO PERSISTIDO — sem campo no contrato interno; a perda é logada (Warn), não silenciosa."
                  },
                  "Metadata": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    },
                    "description": "ACEITO E NÃO PERSISTIDO — idem UserRef."
                  }
                }
              }
            }
          }
        },
        "x-asender-divergence": "`UserRef` e `Metadata` são aceitos e descartados (logados em Warn). O\ncontrato de devices do asender_messages não tem esses campos.\n",
        "responses": {
          "200": {
            "description": "Device registrado ou reativado.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Device": {
                          "$ref": "#/components/schemas/Device"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationDetails"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TenantSuspended"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/Throttled"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "pushH.RegisterDevice",
        "x-asender-fonte": "spec+codigo"
      },
      "get": {
        "operationId": "PublicListDevices",
        "x-asender-authz": "apikey",
        "tags": [
          "public-push"
        ],
        "summary": "Lista os devices de push da conta da API key.",
        "description": "Contraparte de leitura do registro: quem envia token precisa poder\nconferir o que está registrado. O tenant vem da API key, NUNCA da query —\naceitar `tenant_id` do cliente aqui seria vazamento cross-tenant.\n",
        "parameters": [
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Página de devices, em PascalCase.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "Devices",
                    "NextCursor",
                    "HasMore"
                  ],
                  "properties": {
                    "Devices": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "NextCursor": {
                      "type": "integer"
                    },
                    "HasMore": {
                      "type": "boolean"
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "pushH.ListDevices",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/account": {
      "get": {
        "operationId": "PublicGetAccount",
        "x-asender-authz": "apikey",
        "tags": [
          "public-account"
        ],
        "summary": "Conta da API key usada, mais o contexto da própria chave.",
        "security": [
          {
            "ApiKeyBearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "TenantId": {
                          "type": "string",
                          "examples": [
                            "acc_demo_sp"
                          ]
                        },
                        "Name": {
                          "type": "string"
                        },
                        "Plan": {
                          "type": "string",
                          "description": "Vem vazio hoje: o core não expõe plano no payload de tenant."
                        },
                        "Status": {
                          "type": "string",
                          "enum": [
                            "active"
                          ]
                        },
                        "ApiKeyId": {
                          "type": "string"
                        },
                        "ApiKeyScope": {
                          "type": "string",
                          "description": "Escopos da chave unidos por vírgula (`emails:send,emails:read`)."
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TenantSuspended"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "429": {
            "$ref": "#/components/responses/Throttled"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "accountH.Get",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/account/usage": {
      "get": {
        "operationId": "PublicGetUsage",
        "x-asender-authz": "apikey",
        "tags": [
          "public-account"
        ],
        "summary": "Contadores de consumo do tenant no período corrente.",
        "security": [
          {
            "ApiKeyBearer": []
          }
        ],
        "x-asender-divergence": "**Histórico — CORRIGIDO durante esta rodada, mantido por rastreabilidade.**\nO `asender-core` respondia `404 NotFound` quando o tenant não tinha\nlinha em `core.usage_counters` (o caso de TODO tenant do seed), e este\nhandler — que só trata 404 em `GET /v1/account`, não aqui — traduzia\nisso em **502 \"Could not load usage.\"**, como se o core estivesse fora.\nO core passou a devolver contadores ZERADOS nesse caso, e a rota\nresponde 200. O `502` abaixo continua documentado porque segue sendo a\nresposta quando o core está de fato indisponível.\n",
        "responses": {
          "200": {
            "description": "Consumo do período. Tenant sem nenhum envio devolve os contadores em\nzero, com `UpdatedAt: null` — ausência de linha é consumo zero, não\nausência de recurso.\n",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Usage": {
                          "$ref": "#/components/schemas/Usage"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TenantSuspended"
          },
          "429": {
            "$ref": "#/components/responses/Throttled"
          },
          "502": {
            "description": "Core fora **ou** tenant sem contador de uso (ver `x-asender-divergence`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "description": "contadores de consumo do tenant da API key (mês, enviados, quota).\n\nOnde é usada: superfície pública `/v1`, consumida por integrações de cliente. Fluxo do dado: API key verificada → Principal.TenantID → asender-core GET /v1/tenants/{id}/usage (snake_case) → PascalMap → resposta pública.\n\nSaídas: 200 com `{Usage:{Period,EmailsSent,SmsSent,PushSent,ApiCalls}}`; 404 se o tenant da API key não existe mais no core; 502 se o core está fora. DEFEITO QUE ISTO TAMBÉM FECHA (item 7 do briefing Y2, metade da borda): o core respondia 404 quando não havia linha em `core.usage_counters` — e o seed não cria nenhuma —, e este handler traduzia QUALQUER erro do upstream em 502. Ou seja: a rota respondia \"serviço indisponível\" para todo tenant que ainda não enviou nada, mandando o alerta para o time errado. O 404 do core agora só significa \"tenant inexistente\" (a correção principal está em asender-core/internal/service/usage.Get) e é traduzido como 404, não 502. DEFEITO QUE ISTO FECHA (T3 §3.6): o payload do core saía CRU sob `Usage` (`{\"Usage\":{\"sent\":42}}`), fora do contrato PascalCase da API pública (API_DESIGN.md §51). O cliente que segue o contrato lê `Usage.Sent` e recebe `undefined` — mesma classe de divergência de fronteira do GET /api/tenants.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "accountH.Usage",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/login": {
      "post": {
        "operationId": "BffLogin",
        "x-asender-authz": "public",
        "tags": [
          "bff-auth"
        ],
        "summary": "Autentica e devolve o token de sessão.",
        "description": "Corpo aceito em snake_case (`{\"email\",\"password\"}`) porque é o que o\n`asender-auth` define e o proxy repassa; a RESPOSTA é PascalCase.\n\nCom 2FA habilitado a resposta é 200 com `Token:\"\"` e\n`TwoFactorRequired:true` — explícito, para o chamador não confundir com\nupstream quebrado.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "examples": [
                      "demo@asender.local"
                    ]
                  },
                  "password": {
                    "type": "string",
                    "format": "password"
                  }
                }
              }
            }
          }
        },
        "x-asender-divergence": "Oráculo de tempo: login de usuário existente leva ~200 ms (bcrypt) e de\ninexistente ~1 ms. Permite enumerar contas (RELATORIO-FINAL §3 item 9).\n",
        "responses": {
          "200": {
            "description": "Sessão criada, ou 2FA pendente.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Token": {
                          "type": "string",
                          "description": "JWT. Vazio quando `TwoFactorRequired`."
                        },
                        "UserId": {
                          "type": "string"
                        },
                        "UserEmail": {
                          "type": "string"
                        },
                        "ExpiresAt": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "TwoFactorRequired": {
                          "type": "boolean"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Conta bloqueada ou desabilitada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.Login",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/register": {
      "post": {
        "operationId": "BffRegister",
        "x-asender-authz": "public",
        "tags": [
          "bff-auth"
        ],
        "summary": "Cria usuário e (best-effort) o tenant raiz dele.",
        "description": "A criação do tenant é best-effort: se falhar, a resposta ainda é 201\ncom `Tenant: null` e um campo `Note` dizendo para repetir via\n`POST /api/tenants`. Falha silenciosa não é opção.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "Email",
                  "Password"
                ],
                "properties": {
                  "Email": {
                    "type": "string",
                    "format": "email"
                  },
                  "Password": {
                    "type": "string",
                    "minLength": 8
                  },
                  "Name": {
                    "type": "string"
                  },
                  "TenantName": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Usuário criado.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "UserId": {
                          "type": "string"
                        },
                        "UserEmail": {
                          "type": "string"
                        },
                        "Tenant": {
                          "oneOf": [
                            {
                              "type": "null"
                            },
                            {
                              "type": "object",
                              "properties": {
                                "Tenant": {
                                  "$ref": "#/components/schemas/Tenant"
                                }
                              }
                            }
                          ]
                        },
                        "Note": {
                          "type": "string"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.Register",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/2fa": {
      "post": {
        "operationId": "BffTwoFactorLogin",
        "x-asender-authz": "public",
        "tags": [
          "bff-auth"
        ],
        "summary": "Segunda etapa do login — confirma o código do segundo fator.",
        "responses": {
          "200": {
            "description": "OK"
          }
        },
        "description": "troca o código do autenticador pela sessão.\n\nOnde é usada: tela `/2fa`, para onde o login manda quem tem segundo fator.\n\nEfeitos: cria sessão no asender-auth. # Por que esta rota é PÚBLICA Quem chega aqui ainda não tem sessão — é justamente o que ela está tentando obter. Exigir sessão tornaria o 2FA impossível de completar: a armadilha de aplicar a mesma guarda em toda rota \"porque é mais seguro\". O que protege é o par (user_id, código): o `user_id` sozinho não abre nada, e o código vale 30 segundos. Sem esta rota, o login de quem tem 2FA ficava sem passo seguinte — a conta ficava inacessível pelo painel.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.TwoFactor",
        "x-asender-fonte": "spec+codigo"
      },
      "get": {
        "operationId": "BffTwoFactorState",
        "x-asender-authz": "session",
        "tags": [
          "bff-auth"
        ],
        "summary": "Estado do segundo fator do usuário da sessão.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "diz se a conta da sessão tem TOTP CONFIRMADO.\n\nOnde é usada: tela de segurança do painel.\n\nEfeitos: uma leitura no asender-auth. Responde o estado CONFIRMADO, e não \"existe segredo\": um setup interrompido deixa segredo gravado sem confirmação, e mostrar \"2FA ligado\" aí faria a pessoa acreditar numa proteção que o login não exige.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.EstadoDoSegundoFator",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/verify": {
      "post": {
        "operationId": "BffVerifyEmail",
        "x-asender-authz": "public",
        "tags": [
          "bff-auth"
        ],
        "summary": "Confirma o e-mail a partir do token do link.",
        "responses": {
          "200": {
            "description": "OK"
          }
        },
        "description": "repassa o token do link ao asender-auth.\n\nOnde é usada: tela `/verify`, com o token da URL.\n\nEfeitos: carimba `email_verified_at` no auth. # Também é PÚBLICA Quem abre o link do e-mail pode estar em outro navegador, ou nem ter sessão. Exigir login para verificar o e-mail cria a dependência circular clássica — e a pessoa que mais precisa verificar é justamente a que ainda não conseguiu entrar.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.Verify",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/logout": {
      "post": {
        "operationId": "BffLogout",
        "x-asender-authz": "bearer-unverified",
        "tags": [
          "bff-auth"
        ],
        "summary": "Revoga a sessão do Bearer apresentado. IDEMPOTENTE.",
        "description": "Revoga no `asender-auth`; a validação de sessão passou a consultar o\nestado, então o token deixa de valer (401 no `/api/auth/me` seguinte).\n\n**Esta rota fica FORA do grupo `SessionAuth`, de propósito** — é o único\ncaso em `/api/*`. Dentro do grupo, o segundo logout (retry, aba\nduplicada, refresh) morria em 401 no middleware, quebrando a\nidempotência de uma operação que descreve um ESTADO desejado (\"estar\nfora\"), não uma transição.\n\nConsequência observável, e é por isso que a classe declarada é\n`bearer-unverified` e não `session`: o handler exige o header\n`Authorization` (sem ele, 401) mas **não valida a sessão**. Qualquer\nstring não vazia como Bearer — inclusive uma API key ou lixo — recebe\n**200**. Não abre nada (revogar um token que não existe é no-op no\n`asender-auth`), mas o 401 desta rota vem do HANDLER, não do middleware:\nquem for auditar a superfície não pode contá-la como autenticada.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "Revogado, ou já estava — inclusive para um Bearer que nunca foi sessão.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Status": {
                          "type": "string",
                          "const": "ok"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.Logout",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/me": {
      "get": {
        "operationId": "BffMe",
        "x-asender-authz": "session",
        "tags": [
          "bff-auth"
        ],
        "summary": "Principal da sessão.",
        "description": "`TenantId` vem do `asender-auth` e **costuma vir vazio** — a resolução\nreal do tenant é feita por rota (`resolveSessionTenant`), a partir dos\nvínculos no core. Não use este campo como escopo.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "UserId": {
                          "type": "string"
                        },
                        "UserEmail": {
                          "type": "string"
                        },
                        "TenantId": {
                          "type": "string"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.Me",
        "x-asender-fonte": "spec+codigo"
      },
      "patch": {
        "operationId": "BffUpdateMe",
        "x-asender-authz": "session",
        "tags": [
          "bff-auth"
        ],
        "summary": "Altera o perfil do usuário da sessão.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "altera nome e/ou e-mail do usuário DA SESSÃO.\n\nOnde é usada: tela de perfil do painel.\n\nEfeitos: escreve no asender-auth; e-mail novo desverifica a conta e dispara a verificação do endereço novo (lá).",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.UpdateMe",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/me/verify/resend": {
      "post": {
        "operationId": "BffResendVerification",
        "x-asender-authz": "session",
        "tags": [
          "bff-auth"
        ],
        "summary": "Reenvia o e-mail de verificação do usuário da sessão.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "pede ao asender-auth um link de verificação novo para o usuário da sessão.\n\nOnde é usada: aviso \"confirme seu e-mail\" do painel.\n\nEfeitos: um e-mail (o guard do auth decide se ele sai fora de prd). O 429 do upstream é REPASSADO como 429: quem pediu demais precisa saber que basta esperar, e um 502 aqui mandaria a pessoa procurar defeito onde não há.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.ResendVerification",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/me/emails": {
      "get": {
        "tags": [
          "api"
        ],
        "summary": "histórico paginado dos emails do tenant. Era 501; agora lê do asender_messages com o canal fixado em `email`.",
        "description": "histórico paginado dos emails do tenant. Era 501; agora lê do asender_messages com o canal fixado em `email`.\n\nOnde é usada: API pública do cliente. Fluxo do dado: API key → Principal.TenantID → GET /v1/messages?channel=email.\n\nEntradas: `status`, `q`, `cursor`, `limit` (allowlist). `channel` do cliente é IGNORADO: nesta rota o canal é do contrato, não do parâmetro.\n\nSaídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "emailsH.List",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "api"
        ],
        "summary": "Add: POST /api/auth/me/emails.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "emailsH.Add",
        "x-asender-fonte": "codigo"
      }
    },
    "/api/auth/me/emails/{id}": {
      "delete": {
        "tags": [
          "api"
        ],
        "summary": "Remove: DELETE /api/auth/me/emails/{id}.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "emailsH.Remove",
        "x-asender-fonte": "codigo"
      }
    },
    "/api/auth/me/emails/{id}/primary": {
      "post": {
        "tags": [
          "api"
        ],
        "summary": "Primary: POST /api/auth/me/emails/{id}/primary.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "emailsH.Primary",
        "x-asender-fonte": "codigo"
      }
    },
    "/api/auth/me/emails/{id}/verify/resend": {
      "post": {
        "tags": [
          "api"
        ],
        "summary": "Resend: POST /api/auth/me/emails/{id}/verify/resend.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "emailsH.Resend",
        "x-asender-fonte": "codigo"
      }
    },
    "/api/auth/2fa/setup": {
      "post": {
        "operationId": "BffTwoFactorSetup",
        "x-asender-authz": "session",
        "tags": [
          "bff-auth"
        ],
        "summary": "Inicia a ativação do segundo fator.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "começa a ativação e devolve o segredo e a URL do QR.\n\nOnde é usada: tela de segurança.\n\nEfeitos: grava o segredo PENDENTE de confirmação. # A senha é a invariante do piso 17, e ela para AQUI se faltar Para mexer num fator é preciso apresentar um fator diferente dele. O serviço recusa senha vazia, e o gateway recusa antes — não por desconfiança do serviço, mas porque um corpo sem senha é pedido malformado, e mandá-lo adiante gastaria uma viagem para receber a mesma recusa.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.LigarSegundoFator",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/2fa/verify": {
      "post": {
        "operationId": "BffTwoFactorConfirm",
        "x-asender-authz": "session",
        "tags": [
          "bff-auth"
        ],
        "summary": "Confirma a ativação do segundo fator.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "conclui a ativação com um código do aplicativo.\n\nOnde é usada: tela de segurança, depois de ler o QR.\n\nEfeitos: o login passa a EXIGIR o segundo fator.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.ConfirmarSegundoFator",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/2fa/disable": {
      "post": {
        "operationId": "BffTwoFactorDisable",
        "x-asender-authz": "session",
        "tags": [
          "bff-auth"
        ],
        "summary": "Desliga o segundo fator.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "remove o TOTP da conta da sessão.\n\nOnde é usada: tela de segurança.\n\nEfeitos: o login deixa de exigir o segundo fator. Exige a SENHA, e não o código: aceitar o próprio TOTP para removê-lo faria o fator se autorizar sozinho.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.DesligarSegundoFator",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/sessions": {
      "get": {
        "operationId": "BffListSessions",
        "x-asender-authz": "session",
        "tags": [
          "bff-auth"
        ],
        "summary": "Dispositivos e sessões ativas do usuário.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "lista onde a conta do usuário da sessão está aberta.\n\nOnde é usada: tela de segurança do painel.\n\nEfeitos: uma leitura no asender-auth. O token repassado é o da REQUISIÇÃO, e é ele que define de quem é a lista. Não existe parâmetro de usuário: um `?user_id=` seria a rota mais barata do sistema para descobrir de onde outra pessoa se conecta.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.Sessoes",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/auth/sessions/{id}": {
      "delete": {
        "operationId": "BffRevokeSession",
        "x-asender-authz": "session",
        "tags": [
          "bff-auth"
        ],
        "summary": "Revoga uma sessão específica.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "derruba uma sessão do próprio usuário.\n\nOnde é usada: botão \"remover\" da tela de segurança.\n\nEfeitos: a sessão para de autenticar imediatamente. # Por que a impersonação NÃO passa por aqui A sessão de suporte é somente leitura (ADR-0017), e o middleware de impersonação já recusa escrita. Isto é uma escrita — e derrubar o dispositivo de um cliente durante uma sessão de suporte é exatamente o poder que o ADR tira de quem entra na conta alheia.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "authH.RevogarSessao",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants": {
      "get": {
        "operationId": "BffListTenants",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Vínculos de tenant do usuário logado.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Tenants": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Tenant"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "description": "histórico paginado dos emails do tenant. Era 501; agora lê do asender_messages com o canal fixado em `email`.\n\nOnde é usada: API pública do cliente. Fluxo do dado: API key → Principal.TenantID → GET /v1/messages?channel=email.\n\nEntradas: `status`, `q`, `cursor`, `limit` (allowlist). `channel` do cliente é IGNORADO: nesta rota o canal é do contrato, não do parâmetro.\n\nSaídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "tenantsH.List",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "BffCreateTenant",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Cria um tenant RAIZ pertencente ao usuário logado.",
        "description": "**Allowlist tipada de dois campos.** `parent_id`, `monthly_quota`,\n`status`, `plan`, `owner_user_id` e `id` são IGNORADOS e registrados —\nem nível ERROR, como tentativa de escalonamento de privilégio. O\n`owner_user_id` vem sempre do principal verificado.\n\nIgnorar (em vez de 422) preserva chamadores antigos que mandam\n`owner_user_id`; ignorar aqui **não é engolir**: fica no log com o\nusuário que enviou.\n\nPara pendurar um tenant sob outro use `POST /api/tenants/{id}/children`\n(autoriza o pai) ou `PATCH /api/tenants/{id}/parent` (autoriza os dois\nlados). É o único caminho autorizado para mexer na árvore.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "Name"
                ],
                "properties": {
                  "Name": {
                    "type": "string",
                    "minLength": 1
                  },
                  "Slug": {
                    "type": "string"
                  }
                },
                "additionalProperties": {
                  "description": "Aceito no wire e DESCARTADO; ver descrição."
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Tenant criado. **O objeto vem NO TOPO, sem a chave `Tenant`** — o\n`asender-core` responde o tenant sem chave de recurso nesta rota e o\nBFF só pascaliza o que recebeu. É inconsistente com\n`POST /api/tenants/{id}/children` e com `POST /api/auth/register`,\nque devolvem `{\"Tenant\":{...}}`. Documentado como está no ar.\n",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "$ref": "#/components/schemas/Tenant"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "tenantsH.Create",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants/tree": {
      "get": {
        "operationId": "BffTenantTree",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Subárvore do tenant corrente, em pré-ordem.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "name": "depth",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "TenantId": {
                          "type": "string"
                        },
                        "Tree": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/TenantNode"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "description": "a subárvore do tenant corrente, para o seletor de tenant e a tela de hierarquia.\n\nOnde é usada: dashboard (settings/tenants) e backoffice. Fluxo do dado: sessão → tenant resolvido → asender-core GET /v1/tenants/{id}/tree → PascalCase.\n\nEntradas: `depth` — inteiro positivo; valor não numérico é 422, não \"sem limite\" silencioso.\n\nSaídas: 200 com `{TenantId, Tree:[...]}`; 404 se o tenant pedido não é acessível.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "treeH.Tree",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants/{id}/children": {
      "post": {
        "operationId": "BffCreateChildTenant",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Cria uma sub-conta sob um tenant administrado pelo usuário.",
        "description": "Autoriza `{id}` por vínculo direto **ou herdado** (ser membro de um\nancestral manda na subárvore). `MonthlyQuota` ausente/nulo = herda do\nancestral mais próximo com valor.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "Name"
                ],
                "additionalProperties": false,
                "properties": {
                  "Name": {
                    "type": "string",
                    "minLength": 1
                  },
                  "Slug": {
                    "type": "string"
                  },
                  "MonthlyQuota": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "minimum": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Sub-conta criada.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Tenant": {
                          "$ref": "#/components/schemas/TenantNode"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "treeH.CreateChild",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants/{id}/parent": {
      "patch": {
        "operationId": "BffMoveTenant",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Move o tenant para outro pai (ou para a raiz).",
        "description": "**Autoriza os DOIS lados**: `{id}` e o `ParentId` de destino. Autorizar\nsó a origem deixaria pendurar um tenant na árvore de outro cliente, que\npassaria a vê-lo em rollup de relatório.\n\nCorpo com EXATAMENTE um campo `ParentId`. `null` promove a raiz; campo\nausente é 422 (um `{}` acidental não pode promover uma sub-conta).\nCiclo → 422 (o trigger de closure do core levanta `check_violation`),\nnunca 500.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ParentId"
                ],
                "additionalProperties": false,
                "properties": {
                  "ParentId": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Movido; a closure foi reescrita.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Tenant": {
                          "$ref": "#/components/schemas/TenantNode"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "treeH.MoveParent",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants/{id}/quota": {
      "patch": {
        "operationId": "BffSetTenantQuota",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Define ou limpa a quota mensal do tenant.",
        "description": "Corpo com EXATAMENTE um campo `MonthlyQuota`. `null` volta a herdar do\nancestral mais próximo. Campo ausente é 422 — um nome digitado errado\nnão pode limpar a quota em silêncio.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "MonthlyQuota"
                ],
                "additionalProperties": false,
                "properties": {
                  "MonthlyQuota": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "minimum": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Quota gravada; `EffectiveQuota` já recalculada.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Tenant": {
                          "$ref": "#/components/schemas/TenantNode"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "treeH.SetQuota",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/messages/send": {
      "post": {
        "operationId": "BffSendMessage",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Composer do dashboard — envia em qualquer canal.",
        "description": "Corpo em PascalCase, campo a campo espelhando o contrato interno.\nCampo desconhecido é **400** (`DisallowUnknownFields`), não silêncio.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "Channel",
                  "To"
                ],
                "properties": {
                  "Channel": {
                    "$ref": "#/components/schemas/Channel"
                  },
                  "From": {
                    "type": "string"
                  },
                  "To": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string"
                    }
                  },
                  "Subject": {
                    "type": "string"
                  },
                  "BodyText": {
                    "type": "string"
                  },
                  "BodyHtml": {
                    "type": "string"
                  },
                  "Template": {
                    "type": "string",
                    "description": "Slug do template. Variável declarada e não informada é 422."
                  },
                  "Variables": {
                    "type": "object",
                    "additionalProperties": true
                  },
                  "IdempotencyKey": {
                    "type": "string"
                  },
                  "Metadata": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Enfileirado.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Messages": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Message"
                          }
                        },
                        "ReusedIdempotency": {
                          "type": "boolean"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.SendMessage",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/messages": {
      "get": {
        "operationId": "BffListMessages",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Histórico de envios do tenant corrente.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "name": "channel",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Channel"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/MessageStatus"
            }
          },
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessagePage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "description": "página de mensagens do tenant, com filtros de canal/status/busca.\n\nOnde é usada: tela de histórico do dashboard.\n\nEntradas: Channel/Status/Q/Cursor/Limit são aceitos em snake_case (`channel`, `status`, `q`, `cursor`, `limit`) e repassados por allowlist — nenhum outro parâmetro atravessa.\n\nSaídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.ListMessages",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/messages/{id}": {
      "get": {
        "operationId": "BffGetMessage",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Detalhe do envio com timeline.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "$ref": "#/components/parameters/MessageId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageDetail"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "description": "uma mensagem com corpo, metadata e a trilha de eventos.\n\nOnde é usada: tela de detalhe do envio.\n\nSaídas: 200 com `{Message, Events}`; 404 quando o id não existe NO TENANT — mensagem de outro tenant também é 404, para não confirmar existência (§22.8).",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.GetMessage",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/templates": {
      "get": {
        "operationId": "BffListTemplates",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Templates do tenant.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Templates": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Template"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "description": "templates do tenant, para a tela de templates e o seletor do composer.\n\nOnde é usada: dashboard.\n\nSaídas: 200 com `{Templates}`.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.ListTemplates",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "BffUpsertTemplate",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Cria ou atualiza um template pelo par (tenant, slug).",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "Slug",
                  "Name",
                  "Channel"
                ],
                "properties": {
                  "Slug": {
                    "type": "string"
                  },
                  "Name": {
                    "type": "string"
                  },
                  "Channel": {
                    "$ref": "#/components/schemas/Channel"
                  },
                  "Subject": {
                    "type": "string"
                  },
                  "BodyHtml": {
                    "type": "string"
                  },
                  "BodyText": {
                    "type": "string"
                  },
                  "Variables": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Gravado (upsert — 200 tanto na criação quanto na atualização).",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Template": {
                          "$ref": "#/components/schemas/Template"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "description": "gravação idempotente do template pelo par (tenant, slug).\n\nOnde é usada: editor de templates do dashboard.\n\nSaídas: 200 com `{Template}`; 422 em validação do asender_messages.\n\nEfeitos: escreve em messages.templates.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.UpsertTemplate",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/push/devices": {
      "get": {
        "operationId": "BffListDevices",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Devices de push do tenant, para o seletor da tela de disparo.",
        "description": "Fonte do seletor de dispositivos em `/t/{conta}/send/push`. Antes de\nexistir, o caminho respondia 404 e o seletor ficava vazio mesmo com\ndevices no banco — a tela levava o usuário a concluir que a conta não\ntinha dispositivo nenhum.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Página de devices, em PascalCase.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "Devices",
                    "NextCursor",
                    "HasMore"
                  ],
                  "properties": {
                    "Devices": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "NextCursor": {
                      "type": "integer"
                    },
                    "HasMore": {
                      "type": "boolean"
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.ListDevices",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/push/site-config": {
      "get": {
        "operationId": "BffPushSiteConfig",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Pacote de integração de Web Push para o site do cliente.",
        "description": "Devolve, num JSON só, tudo que o site precisa para receber Web Push:\nchave pública VAPID do tenant, `manifest.json`, o service worker e o\nsnippet de inscrição — os três já preenchidos com os valores desta\ninstalação. Gerar no servidor evita o chamado clássico do integrador que\nesqueceu de substituir um `<SUA_CHAVE_AQUI>`.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          }
        ],
        "responses": {
          "200": {
            "description": "Artefatos prontos para o site.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "PublicKey",
                    "ApiBase",
                    "Manifest",
                    "ServiceWorker",
                    "Snippet"
                  ],
                  "properties": {
                    "PublicKey": {
                      "type": "string"
                    },
                    "ApiBase": {
                      "type": "string"
                    },
                    "TenantId": {
                      "type": "string"
                    },
                    "Manifest": {
                      "type": "object"
                    },
                    "ServiceWorker": {
                      "type": "string"
                    },
                    "Snippet": {
                      "type": "string"
                    },
                    "Instructions": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "siteCfgH.SiteConfig",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/contacts": {
      "get": {
        "operationId": "BffListContacts",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Audiência do tenant.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "list_id",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "$ref": "#/components/schemas/CursorPage"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Contacts": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Contact"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "description": "página de contatos do tenant, com busca e filtro por lista.\n\nOnde é usada: tela de audiência do dashboard.\n\nEntradas: `q`, `list_id`, `cursor`, `limit` (allowlist).\n\nSaídas: 200 com `{Contacts, NextCursor, HasMore}`.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.ListContacts",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "BffCreateContact",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Cria um contato.",
        "description": "`Subscribed` ausente vira `true` — opt-in é o default do cadastro manual.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Criado.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Contact": {
                          "$ref": "#/components/schemas/Contact"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.CreateContact",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/contacts/bulk": {
      "post": {
        "operationId": "BffBulkContacts",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Importa até 5000 contatos, com upsert pela identidade natural.",
        "description": "Import parcial é resultado legítimo: itens ruins voltam em `Errors` com\no índice de origem, e o lote não é invalidado por causa deles. Lote\nvazio ou acima do teto é 422.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "Contacts"
                ],
                "properties": {
                  "Contacts": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 5000,
                    "items": {
                      "$ref": "#/components/schemas/ContactInput"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Lote processado.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Created": {
                          "type": "integer"
                        },
                        "Updated": {
                          "type": "integer"
                        },
                        "Errors": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "Index": {
                                "type": "integer"
                              },
                              "Email": {
                                "type": "string"
                              },
                              "Phone": {
                                "type": "string"
                              },
                              "Error": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "Contacts": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Contact"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.BulkContacts",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/lists": {
      "get": {
        "operationId": "BffListLists",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Listas de contatos com contagem de membros.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Lists": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/ContactList"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "description": "listas de contatos do tenant, com contagem de membros.\n\nOnde é usada: tela de audiência.\n\nSaídas: 200 com `{Lists}`.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.ListLists",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "BffCreateList",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Cria uma lista de contatos.",
        "description": "O upstream faz upsert por slug; este BFF responde sempre 201.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "Name"
                ],
                "properties": {
                  "Name": {
                    "type": "string",
                    "minLength": 1
                  },
                  "Slug": {
                    "type": "string"
                  },
                  "Description": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Criada (ou atualizada pelo slug).",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "List": {
                          "$ref": "#/components/schemas/ContactList"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.CreateList",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/lists/{id}/members": {
      "post": {
        "operationId": "BffAddListMembers",
        "x-asender-authz": "session",
        "tags": [
          "bff-messaging"
        ],
        "summary": "Anexa contatos a uma lista.",
        "description": "`NotFound` traz os ids que não existem no tenant — o servidor diz o que\nNÃO pôde fazer, em vez de descartar em silêncio. Idempotente.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Public id da lista (`lst_<hex>`)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "ContactIds"
                ],
                "properties": {
                  "ContactIds": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Added": {
                          "type": "integer"
                        },
                        "NotFound": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Validation"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "msgBFF.AddListMembers",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/reports/overview": {
      "get": {
        "operationId": "BffReportOverview",
        "x-asender-authz": "session",
        "tags": [
          "bff-reports"
        ],
        "summary": "Funil do período com quebra por canal.",
        "description": "Os três canais SEMPRE aparecem em `ByChannel`, zerados quando não houve\ntráfego. As chaves de `ByChannel` (`email`/`sms`/`push`) ficam em\nminúsculas porque são DADO, não nome de campo.\n\nTaxas em `[0,1]`, arredondadas a 4 casas, grampeadas no teto (a base\npode ter `bounced > sent`, porque bounce não exige `sent_at`).\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Totals": {
                          "$ref": "#/components/schemas/ReportCounters"
                        },
                        "Rates": {
                          "$ref": "#/components/schemas/ReportRates"
                        },
                        "ByChannel": {
                          "type": "object",
                          "propertyNames": {
                            "enum": [
                              "email",
                              "sms",
                              "push"
                            ]
                          },
                          "additionalProperties": {
                            "type": "object",
                            "properties": {
                              "Totals": {
                                "$ref": "#/components/schemas/ReportCounters"
                              },
                              "Rates": {
                                "$ref": "#/components/schemas/ReportRates"
                              }
                            }
                          }
                        },
                        "Period": {
                          "$ref": "#/components/schemas/ReportPeriod"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "reportsBFF.Overview",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/reports/timeseries": {
      "get": {
        "operationId": "BffReportTimeseries",
        "x-asender-authz": "session",
        "tags": [
          "bff-reports"
        ],
        "summary": "Série temporal por bucket e canal.",
        "description": "Um ponto por (bucket, canal), buckets vazios inclusos. O campo continua\nse chamando `Day` mesmo com `interval=hour` — é o início do bucket; a\ngranularidade vem em `Period.Interval`.\n\nEste BFF aceita `hour|day|week|month`, mas o `asender_messages` só\nimplementa `day|hour`: `week`/`month` passam a validação daqui e são\nrecusados com 422 pelo upstream.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          },
          {
            "name": "channel",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Channel"
            }
          },
          {
            "name": "interval",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "hour",
                "day",
                "week",
                "month"
              ],
              "default": "day"
            },
            "description": "`week` e `month` são aceitos aqui e recusados pelo upstream (422)."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Points": {
                          "type": "array",
                          "items": {
                            "allOf": [
                              {
                                "$ref": "#/components/schemas/ReportCounters"
                              },
                              {
                                "type": "object",
                                "properties": {
                                  "Day": {
                                    "type": "string",
                                    "format": "date-time"
                                  },
                                  "Channel": {
                                    "$ref": "#/components/schemas/Channel"
                                  }
                                }
                              }
                            ]
                          }
                        },
                        "Period": {
                          "allOf": [
                            {
                              "$ref": "#/components/schemas/ReportPeriod"
                            },
                            {
                              "type": "object",
                              "properties": {
                                "Interval": {
                                  "type": "string"
                                }
                              }
                            }
                          ]
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "reportsBFF.Timeseries",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/reports/top-templates": {
      "get": {
        "operationId": "BffReportTopTemplates",
        "x-asender-authz": "session",
        "tags": [
          "bff-reports"
        ],
        "summary": "Ranking de templates por volume, com taxas.",
        "description": "`limit` aceito de 1 a 100 nesta borda; o teto REAL aplicado pelo\nrepositório do `asender_messages` é 50.\n",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantHeader"
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/RequestIdEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "Templates": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "Slug": {
                                "type": "string"
                              },
                              "Name": {
                                "type": "string"
                              },
                              "Total": {
                                "type": "integer"
                              },
                              "Sent": {
                                "type": "integer"
                              },
                              "Delivered": {
                                "type": "integer"
                              },
                              "Opened": {
                                "type": "integer"
                              },
                              "Clicked": {
                                "type": "integer"
                              },
                              "DeliveryRate": {
                                "type": "number"
                              },
                              "OpenRate": {
                                "type": "number"
                              },
                              "ClickRate": {
                                "type": "number"
                              }
                            }
                          }
                        },
                        "Period": {
                          "$ref": "#/components/schemas/ReportPeriod"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NoTenant"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundOrDenied"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "reportsBFF.TopTemplates",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants/{id}/api-keys": {
      "get": {
        "tags": [
          "api"
        ],
        "summary": "ListKeys lista as chaves da conta.",
        "description": "Onde é usada: GET /api/tenants/{id}/api-keys.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "apiKeysH.ListKeys",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "api"
        ],
        "summary": "CreateKey emite uma chave. O texto puro volta UMA vez, no corpo do core.",
        "description": "Onde é usada: POST /api/tenants/{id}/api-keys.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "apiKeysH.CreateKey",
        "x-asender-fonte": "codigo"
      }
    },
    "/api/tenants/{id}/api-keys/{keyId}": {
      "delete": {
        "tags": [
          "api"
        ],
        "summary": "RevokeKey revoga uma chave.",
        "description": "Onde é usada: DELETE /api/tenants/{id}/api-keys/{keyId}.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "apiKeysH.RevokeKey",
        "x-asender-fonte": "codigo"
      }
    },
    "/api/tenants/{id}/members": {
      "get": {
        "operationId": "BffListMembers",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Membros da conta.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "quem tem acesso à conta.\n\nOnde é usada: tela de equipe.\n\nEfeitos: uma leitura no core.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "membersH.ListMembers",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "BffAddMember",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Vincula um usuário à conta.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "dá acesso direto a um usuário que já existe.\n\nOnde é usada: tela de equipe.\n\nEfeitos: uma escrita no core.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "membersH.AddMember",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants/{id}/members/{userId}": {
      "delete": {
        "operationId": "BffRemoveMember",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Desvincula um usuário da conta.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "tira o acesso de alguém.\n\nOnde é usada: tela de equipe.\n\nEfeitos: uma escrita no core.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "membersH.RemoveMember",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants/{id}/invitations": {
      "get": {
        "operationId": "BffListInvitations",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Convites pendentes da conta.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "membersH.ListInvitations",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "BffCreateInvitation",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Convida alguém para a conta.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "convida um e-mail para a conta.\n\nOnde é usada: tela de equipe.\n\nEfeitos: uma escrita no core (e, quando houver envio, um e-mail).",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "membersH.CreateInvitation",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants/{id}/invitations/{token}": {
      "delete": {
        "operationId": "BffRevokeInvitation",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Cancela um convite pendente.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          },
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "cancela um convite que ainda não foi aceito.\n\nOnde é usada: tela de equipe.\n\nEfeitos: uma escrita no core.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "membersH.RevokeInvitation",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/invitations/accept": {
      "post": {
        "operationId": "BffAcceptInvitation",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Aceita um convite e vincula o usuário à conta.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "transforma o convite em associação, para quem está logado.\n\nOnde é usada: tela de convite.\n\nEfeitos: uma escrita no core. # Esta rota NÃO passa por contaAutorizada E não pode: quem aceita ainda NÃO pertence à conta — exigir pertinência aqui tornaria o convite impossível de aceitar. Quem autoriza é o TOKEN, que o core valida (existente, não expirado, não usado), e o usuário vem da sessão.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "membersH.AcceptInvitation",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/tenants": {
      "get": {
        "operationId": "PlatformListTenants",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Lista as contas, na visão da plataforma.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "todas as contas, para o suporte achar a do chamado.\n\nOnde é usada: tela `/impersonate`.\n\nEfeitos: uma leitura no core.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.ListarContas",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/tenants/{id}/enter": {
      "post": {
        "operationId": "PlatformEnterTenant",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Abre sessão de suporte na conta (somente leitura, ADR-0017).",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "abre a entrada de suporte e grava o cookie com o ID dela.\n\nOnde é usada: tela `/impersonate`.\n\nEfeitos: uma escrita no core — e, a partir dela, LEITURA da conta alheia. O motivo é obrigatório: uma trilha sem motivo responde \"alguém entrou\", que é a metade inútil da pergunta que ela existe para responder.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.Entrar",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/impersonation/end": {
      "post": {
        "operationId": "PlatformEndImpersonation",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Encerra a sessão de suporte em andamento.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "encerra a entrada em curso e apaga o cookie.\n\nOnde é usada: botão \"sair da conta\".\n\nEfeitos: a sessão de suporte para NA HORA.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.Sair",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/tenants/{id}/impersonations": {
      "get": {
        "operationId": "BffListImpersonations",
        "x-asender-authz": "session",
        "tags": [
          "bff-tenants"
        ],
        "summary": "Trilha de sessões de suporte na conta.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/TenantPathId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "quem entrou naquela conta, quando e por quê.\n\nOnde é usada: tela de segurança da conta — do CLIENTE.\n\nEfeitos: uma leitura no core. # Esta rota NÃO exige plataforma Ela exige PERTINÊNCIA, como qualquer leitura de conta. Uma trilha que só a plataforma consegue ler serve para a plataforma se defender, não para o cliente se proteger (ADR-0017).",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.Trilha",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/me": {
      "get": {
        "operationId": "PlatformWhoAmI",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Diz se o usuário da sessão é da plataforma.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "responde quem é o operador e confirma que ele é da plataforma.\n\nOnde é usada: o console, ao abrir — é o que decide entre mostrar o console e mostrar \"não disponível\".\n\nEfeitos: uma leitura na allowlist. Existe em vez de o console deduzir de outra rota: sem ela, o front descobriria que não é plataforma pelo 404 da PRIMEIRA rota que chamasse — e mostraria um erro de carregamento onde a resposta certa é \"esta área não é sua\".",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.EuNaPlataforma",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/tenants/{id}": {
      "get": {
        "operationId": "PlatformGetTenant",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Detalhe de uma conta, na visão da plataforma.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "a conta inteira, vista pela plataforma.\n\nOnde é usada: console, ao abrir um cliente.\n\nEfeitos: uma leitura no core. # Por que não reusar `GET /api/tenants/{id}` Aquela rota exige PERTINÊNCIA: o usuário tem de ser membro da conta. Quem opera a plataforma não é membro de nenhuma conta de cliente — e não deve virar, porque virar membro para poder ver é exatamente o atalho que o ADR-0017 existe para impedir. A autorização aqui é a allowlist de plataforma, e a rota é outra.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.ContaDaPlataforma",
        "x-asender-fonte": "spec+codigo"
      },
      "patch": {
        "operationId": "PlatformPatchTenant",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Altera uma conta, na visão da plataforma.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "muda status e metadados da conta, pela plataforma.\n\nOnde é usada: console.\n\nEfeitos: uma escrita no core. # Isto NÃO é a sessão de suporte A impersonação é somente leitura (ADR-0017) porque entrar na conta é ver o que o cliente vê. Isto é outra coisa: é a plataforma agindo COMO plataforma — suspender uma conta abusiva, por exemplo — e a ação fica no log com o ator. A distinção importa: se a sessão de suporte pudesse escrever, \"entrar para ajudar\" viraria \"entrar para consertar\", e o limite que o ADR desenhou desapareceria na prática.",
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.AtualizarContaDaPlataforma",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/metrics": {
      "get": {
        "operationId": "PlatformMetrics",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Métricas agregadas da plataforma.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.MetricasDaPlataforma",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/blacklist": {
      "get": {
        "operationId": "PlatformListBlacklist",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Lista os bloqueios da plataforma.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.ListarBlacklist",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "PlatformAddBlacklist",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Acrescenta uma entrada à lista de bloqueio.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.Bloquear",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/blacklist/{id}": {
      "delete": {
        "operationId": "PlatformRemoveBlacklist",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Remove uma entrada da lista de bloqueio.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.Desbloquear",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/blacklist/suggestions": {
      "get": {
        "operationId": "PlatformListBlacklistSuggestions",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Sugestões de bloqueio ainda não decididas.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.ListarSugestoes",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "PlatformCreateBlacklistSuggestion",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Registra uma sugestão de bloqueio.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.Sugerir",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/blacklist/suggestions/{id}/apply": {
      "post": {
        "operationId": "PlatformApplyBlacklistSuggestion",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Aceita a sugestão e a promove a bloqueio.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.AplicarSugestao",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/blacklist/suggestions/{id}/dismiss": {
      "post": {
        "operationId": "PlatformDismissBlacklistSuggestion",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Descarta a sugestão.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.DescartarSugestao",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/datacenter-asn": {
      "get": {
        "operationId": "PlatformListDatacenterASN",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "ASNs classificados como datacenter.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.ListarASNs",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "PlatformAddDatacenterASN",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Classifica um ASN como datacenter.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.AcrescentarASN",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/datacenter-asn/{asn}": {
      "delete": {
        "operationId": "PlatformRemoveDatacenterASN",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Remove a classificação de um ASN.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "asn",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.RemoverASN",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/platform-alerts": {
      "get": {
        "operationId": "PlatformListAlerts",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Regras de alerta da plataforma.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.ListarAlertasDePlataforma",
        "x-asender-fonte": "spec+codigo"
      },
      "post": {
        "operationId": "PlatformCreateAlert",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Cria uma regra de alerta de plataforma.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.CriarAlertaDePlataforma",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/platform-alerts/{id}": {
      "put": {
        "operationId": "PlatformUpdateAlert",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Substitui uma regra de alerta.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.AtualizarAlertaDePlataforma",
        "x-asender-fonte": "spec+codigo"
      },
      "delete": {
        "operationId": "PlatformDeleteAlert",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Remove uma regra de alerta.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.ApagarAlertaDePlataforma",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/api/admin/platform-alerts/events": {
      "get": {
        "operationId": "PlatformListAlertEvents",
        "x-asender-authz": "session",
        "tags": [
          "bff-plataforma"
        ],
        "summary": "Disparos de alerta da plataforma.",
        "security": [
          {
            "SessionBearer": []
          },
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "x-asender-servico": "asender-api",
        "x-asender-handler": "plataformaH.EventosDePlataforma",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/.well-known/openid-configuration": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "anuncia os endpoints e as capacidades (só `code`, só S256, só RS256 — o perfil OAuth2.1 da casa).",
        "description": "anuncia os endpoints e as capacidades (só `code`, só S256, só RS256 — o perfil OAuth2.1 da casa).\n\nOnde é usada: os clients leem isto no bootstrap para se autoconfigurar. Cacheável (muda só em deploy).",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.oidcH.Discovery",
        "x-asender-fonte": "codigo"
      }
    },
    "/.well-known/jwks.json": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "JWKS serve /.well-known/jwks.json — a(s) chave(s) pública(s) RS256. Os clients",
        "description": "Onde é usada: verificação no client.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.oidcH.JWKS",
        "x-asender-fonte": "codigo"
      }
    },
    "/authorize": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Authorize implementa GET /authorize (OAuth2.1 authorization code + PKCE).",
        "description": "Onde é usada: o browser é redirecionado para cá pelo client (app) que quer logar.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.oidcH.Authorize",
        "x-asender-fonte": "codigo"
      }
    },
    "/token": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Token implementa POST /token. Despacha por grant_type.",
        "description": "Onde é usada: o client troca aqui, servidor-a-servidor (ou pelo BFF), o code/refresh por tokens.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.oidcH.Token",
        "x-asender-fonte": "codigo"
      }
    },
    "/userinfo": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "valida o Bearer (RS256, iss, exp) e devolve sub/email/name.",
        "description": "valida o Bearer (RS256, iss, exp) e devolve sub/email/name.\n\nOnde é usada: o client chama para hidratar o perfil. Sem token válido -> 401 invalid_token.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.oidcH.UserInfo",
        "x-asender-fonte": "codigo"
      }
    },
    "/logout": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Expects Authorization: Bearer <token>.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.oidcH.Logout",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/users": {
      "post": {
        "operationId": "AuthRegisterUser",
        "x-asender-authz": "service",
        "tags": [
          "users"
        ],
        "summary": "Cria um usuário.",
        "description": "A rota é declarada como `r.Post(\"/\")` dentro de `r.Route(\"/users\")`, ou\nseja o padrão registrado é `/v1/users/`. **Ambas as formas respondem\n201** (verificado na stack): `POST /v1/users` e `POST /v1/users/`.\n\nSenha é hasheada com bcrypt no custo configurado (`BCRYPT_COST`, ≥ 12).\nO hash NUNCA aparece em resposta alguma.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "email",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "name": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string",
                    "format": "password"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Criado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "user": {
                      "$ref": "#/components/schemas/User"
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ServiceUnauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.usersH.Register",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/users/{id}": {
      "get": {
        "operationId": "AuthGetUser",
        "x-asender-authz": "service",
        "tags": [
          "users"
        ],
        "summary": "Um usuário pelo public id.",
        "parameters": [
          {
            "$ref": "#/components/parameters/UserId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "user": {
                      "$ref": "#/components/schemas/User"
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ServiceUnauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.usersH.Get",
        "x-asender-fonte": "spec+codigo"
      },
      "patch": {
        "operationId": "AuthPatchUser",
        "x-asender-authz": "service",
        "tags": [
          "users"
        ],
        "summary": "Altera nome e/ou email.",
        "description": "Senha NÃO é alterável por aqui — o caminho é `POST /v1/auth/password-reset/confirm`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/UserId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "user": {
                      "$ref": "#/components/schemas/User"
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ServiceUnauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.usersH.Patch",
        "x-asender-fonte": "spec+codigo"
      },
      "delete": {
        "operationId": "AuthDeleteUser",
        "x-asender-authz": "service",
        "tags": [
          "users"
        ],
        "summary": "Remove o usuário.",
        "parameters": [
          {
            "$ref": "#/components/parameters/UserId"
          }
        ],
        "responses": {
          "204": {
            "description": "Removido."
          },
          "401": {
            "$ref": "#/components/responses/ServiceUnauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.usersH.Delete",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/users/{id}/verify/resend": {
      "post": {
        "operationId": "AuthResendVerification",
        "x-asender-authz": "service",
        "tags": [
          "users"
        ],
        "summary": "Reenvia o e-mail de verificação de um usuário.",
        "security": [
          {
            "ServiceToken": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "Onde é usada:  POST /v1/users/{id}/emails/{emailId}/verify/resend.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.verifH.Reenviar",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/users/{id}/emails": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Listar devolve os e-mails do usuário.",
        "description": "Onde é usada: GET /v1/users/{id}/emails.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.emailsH.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Adicionar cria um e-mail secundário (não-verificado) e dispara a verificação.",
        "description": "Onde é usada: POST /v1/users/{id}/emails {email}.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.emailsH.Adicionar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/users/{id}/emails/{emailId}": {
      "delete": {
        "tags": [
          "auth"
        ],
        "summary": "Remover apaga um e-mail secundário.",
        "description": "Onde é usada: DELETE /v1/users/{id}/emails/{emailId}.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.emailsH.Remover",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/users/{id}/emails/{emailId}/primary": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "DefinirPrimario promove um e-mail verificado a primário.",
        "description": "Onde é usada:  POST /v1/users/{id}/emails/{emailId}/primary.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.emailsH.DefinirPrimario",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/users/{id}/emails/{emailId}/verify/resend": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Reenviar redispara a verificação de um e-mail.",
        "description": "Onde é usada:  POST /v1/users/{id}/emails/{emailId}/verify/resend.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.emailsH.Reenviar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/auth/login": {
      "post": {
        "operationId": "AuthLogin",
        "x-asender-authz": "service",
        "tags": [
          "auth"
        ],
        "summary": "Autentica e emite sessão.",
        "description": "Com 2FA ativo, a resposta traz `two_factor_required:true` e **sem\ntoken**; a sessão só é emitida por `POST /v1/2fa/check`.\n\nUser-Agent e IP do chamador entram no registro da sessão (o IP vem do\n`RealIP` do chi, portanto de `X-Forwarded-For` quando houver proxy).\n",
        "x-asender-divergence": "**Oráculo de tempo** (RELATORIO-FINAL §3 item 9): email existente\ncusta o bcrypt (~200 ms), inexistente retorna em ~1 ms. Dá para\nenumerar contas cronometrando. Corrigir exige hash dummy no caminho\nde \"usuário não existe\".\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "email",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "password": {
                    "type": "string",
                    "format": "password"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sessão emitida, ou 2FA pendente.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "login": {
                      "$ref": "#/components/schemas/LoginResult"
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "`401 InvalidCredentials` — credenciais inválidas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authH.Login",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/auth/logout": {
      "post": {
        "operationId": "AuthLogout",
        "x-asender-authz": "session",
        "tags": [
          "auth"
        ],
        "summary": "Revoga a sessão do Bearer apresentado.",
        "description": "Credencial: `Authorization: Bearer <token de sessão>` — **não** o token\nde serviço. Ausente → 401 `MissingToken`.\n\nCorrigido na onda 7: revoga de fato e o `validate` passa a consultar o\nestado da sessão, então o token deixa de valer imediatamente.\n",
        "security": [
          {
            "SessionBearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "Revogada.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/SessionUnauthorized"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authH.Logout",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/auth/validate": {
      "get": {
        "operationId": "AuthValidateSession",
        "x-asender-authz": "session",
        "tags": [
          "auth"
        ],
        "summary": "Resolve um token de sessão no usuário dono.",
        "description": "É o hop que o middleware `SessionAuth` do `asender-api` faz em toda\nrequisição `/api/*` autenticada.\n\n**A resposta DEVE trazer `user.id`.** Um 200 sem ele é violação de\ncontrato: o cliente do `asender-api` recusa com `ErrContractViolation` e\no middleware barra o principal anônimo (defesa em profundidade). Esta\nrota não devolve tenant — quem sabe de tenant é o `asender-core`.\n",
        "security": [
          {
            "SessionBearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "Sessão válida.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "user": {
                      "type": "object",
                      "required": [
                        "id"
                      ],
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "email": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string"
                        }
                      }
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/SessionUnauthorized"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authH.Validate",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/auth/password-reset": {
      "post": {
        "operationId": "AuthPasswordReset",
        "x-asender-authz": "service",
        "tags": [
          "auth"
        ],
        "summary": "Solicita o token de redefinição de senha.",
        "x-asender-divergence": "**O token de reset volta NO CORPO** (`reset_token`) em vez de ser\nenviado por email — há um `TODO(email)` explícito para enfileirar o\nenvio via NATS. Quem alcança esta rota redefine a senha de qualquer\nusuário. Aceitável só porque o serviço é interno; some junto com a\nexposição das portas.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sempre `ok:true`; `reset_token` presente quando um token foi gerado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "reset_token": {
                      "type": "string"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ServiceUnauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authH.PasswordReset",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/auth/password-reset/confirm": {
      "post": {
        "operationId": "AuthPasswordResetConfirm",
        "x-asender-authz": "service",
        "tags": [
          "auth"
        ],
        "summary": "Redefine a senha com o token emitido.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "token",
                  "new_password"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  },
                  "new_password": {
                    "type": "string",
                    "format": "password"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Senha alterada.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "`InvalidToken` ou `ExpiredToken`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authH.PasswordResetConfirm",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/auth/verify": {
      "post": {
        "operationId": "AuthVerifyEmail",
        "x-asender-authz": "service",
        "tags": [
          "auth"
        ],
        "summary": "Confirma o e-mail a partir do token do link.",
        "security": [
          {
            "ServiceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "consome o token do link e carimba `email_verified_at`.\n\nOnde é usada: tela `/verify` do front, com o token da URL.\n\nEfeitos: duas escritas. Token inválido, expirado e já usado respondem IGUAL: distingui-los diria a quem tem um link velho se ele já foi usado por outra pessoa.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.verifH.Confirmar",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/auth/sessions": {
      "get": {
        "operationId": "AuthListSessions",
        "x-asender-authz": "service",
        "tags": [
          "auth"
        ],
        "summary": "Sessões ativas do usuário.",
        "security": [
          {
            "ServiceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "lista onde a conta de quem apresenta o token está aberta.\n\nOnde é usada: tela de segurança do painel, através do BFF.\n\nEfeitos: uma leitura. Não recebe usuário por parâmetro: a lista é sempre a do dono do token. Um `?user_id=` seria a rota mais barata do sistema para descobrir de onde outra pessoa se conecta.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authH.Sessoes",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/auth/sessions/{id}": {
      "delete": {
        "operationId": "AuthRevokeSession",
        "x-asender-authz": "service",
        "tags": [
          "auth"
        ],
        "summary": "Revoga uma sessão específica.",
        "security": [
          {
            "ServiceToken": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "credencial ausente ou inválida"
          }
        },
        "description": "derruba UMA sessão do próprio usuário.\n\nOnde é usada: botão \"remover\" da tela de segurança.\n\nEfeitos: a sessão para de autenticar imediatamente. 404 tanto para sessão inexistente quanto para sessão de OUTRA pessoa: a distinção transformaria a rota num oráculo de id de sessão alheia.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authH.RevogarSessao",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/authz/decide": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "`{permitido, motivo, papel, nivel}`.",
        "description": "`{permitido, motivo, papel, nivel}`.\n\nOnde é usada: POST /v1/authz/decide.\n\nEfeitos: grava a trilha (dentro do PDP). Erro de banco vira 503, e não `{permitido:false}`: o PEP precisa distinguir \"a política diz não\" de \"não consegui perguntar\" — o primeiro ele mostra ao usuário, o segundo ele resolve com o cache que já tem (piso 10).",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authzH.Decidir",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/authz/eu": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "`{produtos:[{slug,nome,subdominio,papel,nivel}]}`.",
        "description": "`{produtos:[{slug,nome,subdominio,papel,nivel}]}`.\n\nOnde é usada:  GET /v1/authz/eu?sub=&tenant= — alimenta o switcher e a home do hub.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authzH.Meus",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/authz/produtos": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Catalogo devolve as ferramentas que existem.",
        "description": "Onde é usada: GET /v1/authz/produtos.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authzH.Catalogo",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/tenants/{id}/produtos": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "ListarAssinatura devolve o que a conta assina.",
        "description": "Onde é usada: GET /v1/tenants/{id}/produtos.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authzH.ListarAssinatura",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Assinar liga ou suspende um produto na conta.",
        "description": "Onde é usada: POST /v1/tenants/{id}/produtos {produto, status}. `status` é validado aqui E no CHECK da tabela: a borda dá a mensagem, o banco dá a garantia — a borda pode ser contornada por outro caminho de escrita.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authzH.Assinar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/tenants/{id}/acessos": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "ListarAcessos devolve quem acessa o quê na conta.",
        "description": "Onde é usada: GET /v1/tenants/{id}/acessos.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authzH.ListarAcessos",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Conceder dá acesso de uma pessoa a uma ferramenta da conta.",
        "description": "Onde é usada: POST /v1/tenants/{id}/acessos {user_id, produto, papel}. Papel que o produto não declara é recusado pela FK — 422 com a mensagem, e não 500: \"papel inventado\" é erro de quem chamou, não do servidor.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authzH.Conceder",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/tenants/{id}/acessos/{userId}/{produto}": {
      "delete": {
        "tags": [
          "auth"
        ],
        "summary": "Revogar tira o acesso.",
        "description": "Onde é usada: DELETE /v1/tenants/{id}/acessos/{userId}/{produto}. Idempotente: revogar duas vezes responde ok nas duas. O efeito chega às ferramentas em até 60s (TTL do cache do PEP) — contrato escrito no ADR-0025.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authzH.Revogar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/tenants/{id}/authz/decisoes": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Trilha devolve as decisões recentes da conta.",
        "description": "Onde é usada: GET /v1/tenants/{id}/authz/decisoes?negadas=1&limite=50.",
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.authzH.Trilha",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/2fa/setup": {
      "post": {
        "operationId": "AuthTwoFASetup",
        "x-asender-authz": "service",
        "tags": [
          "twofa"
        ],
        "summary": "Gera o segredo TOTP e a `otpauth://` URL.",
        "description": "Ainda não confirmado: o 2FA só passa a valer depois de `POST /v1/2fa/verify`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UserIdBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Segredo gerado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "setup": {
                      "type": "object",
                      "properties": {
                        "secret": {
                          "type": "string",
                          "description": "Segredo base32. Sai UMA VEZ."
                        },
                        "otpauth_url": {
                          "type": "string"
                        }
                      }
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ServiceUnauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.twofaH.Setup",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/2fa/verify": {
      "post": {
        "operationId": "AuthTwoFAVerify",
        "x-asender-authz": "service",
        "tags": [
          "twofa"
        ],
        "summary": "Confirma o setup do TOTP.",
        "description": "NÃO emite sessão — isso é o `check`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TwoFACodeBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Confirmado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/TwoFactorFailed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.twofaH.Verify",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/2fa/disable": {
      "post": {
        "operationId": "AuthTwoFADisable",
        "x-asender-authz": "service",
        "tags": [
          "twofa"
        ],
        "summary": "Desliga o TOTP do usuário.",
        "x-asender-divergence": "Aceita só `user_id` — **não exige código TOTP nem senha**. Quem alcança\nesta rota remove o segundo fator de qualquer conta.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UserIdBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Desligado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ServiceUnauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.twofaH.Disable",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/2fa/check": {
      "post": {
        "operationId": "AuthTwoFACheck",
        "x-asender-authz": "service",
        "tags": [
          "twofa"
        ],
        "summary": "Segunda etapa do login — valida o código e EMITE a sessão.",
        "description": "Sucesso devolve o mesmo `login` de `POST /v1/auth/login`, agora com token.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TwoFACodeBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Código válido; sessão emitida.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "login": {
                      "$ref": "#/components/schemas/LoginResult"
                    },
                    "RequestId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/TwoFactorFailed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-asender-servico": "asender-auth",
        "x-asender-handler": "s.twofaH.Check",
        "x-asender-fonte": "spec+codigo"
      }
    },
    "/v1/jornadas": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/alertas.",
        "description": "GET /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "j.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/alertas.",
        "description": "POST /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "j.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/jornadas/{id}": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/contatos/{id}/consentimento.",
        "description": "GET /v1/contatos/{id}/consentimento.\n\nOnde é usada: tela de contato, seção de preferências.\n\nEfeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira que ficasse desatualizada esconderia um canal do operador.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "j.Obter",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/jornadas/{id}/ativa": {
      "put": {
        "tags": [
          "crm"
        ],
        "summary": "PUT /v1/jornadas/{id}/ativa.",
        "description": "PUT /v1/jornadas/{id}/ativa.\n\nOnde é usada: painel, interruptor da jornada.\n\nEfeitos: muda o estado; ligada, a jornada passa a inscrever contatos. # Revalida ANTES de ligar A jornada foi validada na criação, mas pode ter sido editada depois — e uma jornada com buraco na numeração deixaria o contato preso no meio do caminho, sem erro em lugar nenhum. Validar de novo aqui custa nada e é o último ponto antes de a coisa começar a mandar mensagem. Desligar NÃO revalida: uma jornada quebrada tem de poder ser desligada, e exigir que ela esteja válida para parar seria prender o operador justamente no caso em que ele mais precisa do botão.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "j.Ativar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/alertas": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/alertas.",
        "description": "GET /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "ha.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/alertas.",
        "description": "POST /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "ha.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/alertas/eventos": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/alertas/eventos?limite=N.",
        "description": "GET /v1/alertas/eventos?limite=N.\n\nOnde é usada: tela de alertas — a linha do tempo.\n\nEfeitos: uma leitura. Da CONTA, e não de um alerta: quem investiga \"o que aconteceu ontem\" quer a linha do tempo inteira, e paginar por alerta a obrigaria a abrir um por um.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "ha.Eventos",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/alertas/{id}": {
      "put": {
        "tags": [
          "crm"
        ],
        "summary": "PUT /v1/alertas/{id}.",
        "description": "PUT /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria \"disparado\" pela antiga.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "ha.Atualizar",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "crm"
        ],
        "summary": "DELETE /v1/alertas/{id}.",
        "description": "DELETE /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "ha.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/destinos": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/alertas.",
        "description": "GET /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/alertas.",
        "description": "POST /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/destinos/{id}": {
      "put": {
        "tags": [
          "crm"
        ],
        "summary": "PUT /v1/alertas/{id}.",
        "description": "PUT /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria \"disparado\" pela antiga.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Atualizar",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "crm"
        ],
        "summary": "DELETE /v1/alertas/{id}.",
        "description": "DELETE /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/destinos/{id}/entregas": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/destinos/{id}/entregas?limite=N.",
        "description": "GET /v1/destinos/{id}/entregas?limite=N.\n\nOnde é usada: diagnóstico da tela de integrações.\n\nEfeitos: uma leitura. O PAYLOAD não volta: ele carrega a PII do contato, e quem pergunta \"por que não chegou\" precisa do estado e do código — não do dado da pessoa repetido numa segunda tela.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Entregas",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/destinos/{id}/testar": {
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/destinos/{id}/testar.",
        "description": "POST /v1/destinos/{id}/testar.\n\nOnde é usada: botão \"testar\" da tela de integrações.\n\nEfeitos: cria uma entrega pendente. # ENFILEIRA, e não entrega na hora A entrega passa pelo MESMO dreno das entregas de verdade — e portanto pelo mesmo guard do piso 18, pela mesma guarda de rede e pela mesma assinatura. Um caminho de teste que chamasse o webhook direto seria um segundo caminho de saída, e o segundo caminho é sempre o que esquece uma guarda. O operador vê o resultado em `GET /entregas`, que é a mesma tela onde ele vê as entregas reais — então \"o teste funcionou\" e \"a integração funciona\" são a mesma leitura, e não duas afirmações que podem divergir.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Testar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/integracoes-de-conversao": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/alertas.",
        "description": "GET /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/alertas.",
        "description": "POST /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/integracoes-de-conversao/{id}": {
      "put": {
        "tags": [
          "crm"
        ],
        "summary": "PUT /v1/alertas/{id}.",
        "description": "PUT /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria \"disparado\" pela antiga.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Atualizar",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "crm"
        ],
        "summary": "DELETE /v1/alertas/{id}.",
        "description": "DELETE /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/integracoes-de-conversao/{id}/entregas": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/destinos/{id}/entregas?limite=N.",
        "description": "GET /v1/destinos/{id}/entregas?limite=N.\n\nOnde é usada: diagnóstico da tela de integrações.\n\nEfeitos: uma leitura. O PAYLOAD não volta: ele carrega a PII do contato, e quem pergunta \"por que não chegou\" precisa do estado e do código — não do dado da pessoa repetido numa segunda tela.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Entregas",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/integracoes-de-conversao/{id}/testar": {
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/destinos/{id}/testar.",
        "description": "POST /v1/destinos/{id}/testar.\n\nOnde é usada: botão \"testar\" da tela de integrações.\n\nEfeitos: cria uma entrega pendente. # ENFILEIRA, e não entrega na hora A entrega passa pelo MESMO dreno das entregas de verdade — e portanto pelo mesmo guard do piso 18, pela mesma guarda de rede e pela mesma assinatura. Um caminho de teste que chamasse o webhook direto seria um segundo caminho de saída, e o segundo caminho é sempre o que esquece uma guarda. O operador vê o resultado em `GET /entregas`, que é a mesma tela onde ele vê as entregas reais — então \"o teste funcionou\" e \"a integração funciona\" são a mesma leitura, e não duas afirmações que podem divergir.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hd.Testar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/configuracoes/retencao": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/configuracoes/retencao.",
        "description": "GET /v1/configuracoes/retencao.\n\nOnde é usada: tela de configurações da conta.\n\nEfeitos: uma leitura. Conta sem política configurada responde 200 com `dias: null` — e não 404: \"não configurei retenção\" é o estado NORMAL, e a tela precisa dele para desenhar o formulário vazio.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hc.ObterRetencao",
        "x-asender-fonte": "codigo"
      },
      "put": {
        "tags": [
          "crm"
        ],
        "summary": "PUT /v1/configuracoes/retencao com `{\"dias\": 90}` ou `{\"dias\": null}`.",
        "description": "PUT /v1/configuracoes/retencao com `{\"dias\": 90}` ou `{\"dias\": null}`.\n\nOnde é usada: tela de configurações da conta.\n\nEfeitos: uma escrita; passa a anonimizar contato antigo em até uma hora. # `null` desliga, ausente é erro Os dois cairiam no mesmo ponteiro nil se o corpo fosse decodificado direto — e um cliente que esquecesse o campo desligaria a retenção sem querer. `null` é uma ORDEM (\"guarde para sempre\"); campo ausente é um pedido malformado.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hc.DefinirRetencao",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/uso": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/uso?periodo=YYYY-MM — números locais do CRM mais a quota e os contadores do core.",
        "description": "GET /v1/uso?periodo=YYYY-MM — números locais do CRM mais a quota e os contadores do core.\n\nOnde é usada: tela de conta/plano.\n\nEfeitos: uma leitura local e até duas chamadas ao core. # O core fora do ar NÃO derruba esta rota Os números locais (quantos contatos, quantos entraram no mês) são a metade que o CRM sabe sozinho, e são justamente a metade que a tela usa todo dia. Com o core fora, a resposta sai com `core_disponivel: false` — a tela mostra o que tem e diz o que não conseguiu saber (piso 10).",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "hc.Uso",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/contatos": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/alertas.",
        "description": "GET /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "c.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/alertas.",
        "description": "POST /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "c.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/contatos/{id}": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/contatos/{id}/consentimento.",
        "description": "GET /v1/contatos/{id}/consentimento.\n\nOnde é usada: tela de contato, seção de preferências.\n\nEfeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira que ficasse desatualizada esconderia um canal do operador.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "c.Obter",
        "x-asender-fonte": "codigo"
      },
      "patch": {
        "tags": [
          "crm"
        ],
        "summary": "PUT /v1/alertas/{id}.",
        "description": "PUT /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria \"disparado\" pela antiga.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "c.Atualizar",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "crm"
        ],
        "summary": "DELETE /v1/alertas/{id}.",
        "description": "DELETE /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "c.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/contatos/exportar.csv": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/contatos/exportar.csv?limite=N.",
        "description": "GET /v1/contatos/exportar.csv?limite=N.\n\nOnde é usada: botão \"exportar\" da lista de contatos.\n\nEfeitos: uma leitura; decifra a PII; registra a exportação na timeline. # A PII é decifrada AQUI, e só para quem é dono O tenant vem da sessão verificada e a RLS já filtra a consulta. Este handler nunca vê contato de outra conta, e a decifragem acontece com a chave do processo — não há caminho em que o CSV carregue dado alheio. # Cada exportação vira uma ATIVIDADE, e isso não é opcional A LGPD (art. 37) exige registro das operações de tratamento, e exportar é tratamento: a partir dali o dado existe num arquivo que ninguém controla. Sem esse registro, \"quem baixou a base e quando\" não tem resposta — e é a primeira pergunta de todo incidente de vazamento. O registro é gravado ANTES do arquivo sair. Se a gravação falhar, a exportação não acontece: um download sem trilha é exatamente o que a trilha existe para impedir, e \"o arquivo já foi\" não se desfaz.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "c.Exportar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/contatos/{id}/atividades": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/alertas.",
        "description": "GET /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "t.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/alertas.",
        "description": "POST /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "t.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/contatos/{id}/consentimento": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/contatos/{id}/consentimento.",
        "description": "GET /v1/contatos/{id}/consentimento.\n\nOnde é usada: tela de contato, seção de preferências.\n\nEfeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira que ficasse desatualizada esconderia um canal do operador.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "cs.Obter",
        "x-asender-fonte": "codigo"
      },
      "put": {
        "tags": [
          "crm"
        ],
        "summary": "PUT /v1/contatos/{id}/consentimento.",
        "description": "PUT /v1/contatos/{id}/consentimento.\n\nOnde é usada: tela de contato, e o link de descadastro via BFF.\n\nEfeitos: cria ou remove a linha de opt-out. # `recebe` é ponteiro, e a ausência é ERRO Com um `bool` comum, um corpo sem o campo viria como `false` — e \"esqueci de mandar o campo\" seria indistinguível de \"descadastra esta pessoa\". O ponteiro transforma a omissão em 422 explícito.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "cs.Definir",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/segmentos": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/alertas.",
        "description": "GET /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "sg.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/alertas.",
        "description": "POST /v1/alertas.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "sg.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/segmentos/previa": {
      "post": {
        "tags": [
          "crm"
        ],
        "summary": "POST /v1/segmentos/previa.",
        "description": "POST /v1/segmentos/previa.\n\nOnde é usada: enquanto o usuário monta os critérios na tela.\n\nEfeitos: uma consulta; NÃO grava nada. Existe para que a pergunta \"isto alcança quem?\" seja respondida ANTES de salvar. Sem ela, o caminho para descobrir o alcance é criar o segmento — e a tela enche de segmentos descartados chamados \"teste 3\".",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "sg.Previsualizar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/segmentos/{id}": {
      "get": {
        "tags": [
          "crm"
        ],
        "summary": "GET /v1/contatos/{id}/consentimento.",
        "description": "GET /v1/contatos/{id}/consentimento.\n\nOnde é usada: tela de contato, seção de preferências.\n\nEfeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira que ficasse desatualizada esconderia um canal do operador.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "sg.Obter",
        "x-asender-fonte": "codigo"
      },
      "put": {
        "tags": [
          "crm"
        ],
        "summary": "PUT /v1/alertas/{id}.",
        "description": "PUT /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria \"disparado\" pela antiga.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "sg.Atualizar",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "crm"
        ],
        "summary": "DELETE /v1/alertas/{id}.",
        "description": "DELETE /v1/alertas/{id}.\n\nOnde é usada: tela de alertas.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_crm",
        "x-asender-handler": "sg.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/internal/v1/formularios/{id}": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /internal/v1/formularios/{id}.",
        "description": "GET /internal/v1/formularios/{id}.\n\nOnde é usada: chamado pelo `asender_runtime` para validar uma submissão.\n\nEfeitos: uma leitura. # Sem o tenant na rota, de propósito No modo SNIPPET o formulário está embutido no site de um terceiro e quem posta é o navegador de um visitante anônimo. O runtime não sabe de qual conta é o formulário — a conta é a RESPOSTA, tirada dele. Exigir o tenant na rota tornaria esta chamada impossível de fazer. Isto não afrouxa nada: o `public_id` é opaco e já está no HTML de quem visita o site. Quem o conhece já podia postar nele. O que protege esta rota é a credencial de serviço — sem ela, ela não responde nada.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hi.Formulario",
        "x-asender-fonte": "codigo"
      }
    },
    "/internal/v1/paginas": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /internal/v1/paginas?dominio=&slug=.",
        "description": "GET /internal/v1/paginas?dominio=&slug=.\n\nOnde é usada: chamado pelo `asender_runtime` a cada requisição de página pública.\n\nEfeitos: uma leitura. # Query string e não caminho O domínio contém pontos e o slug pode conter hífen; os dois em segmentos de rota exigiriam escape que o roteador desfaz de formas diferentes conforme a versão. Query string é literal, e o que trafega aqui é rede interna.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hi.PaginaPublicada",
        "x-asender-fonte": "codigo"
      }
    },
    "/internal/v1/paginas/publicadas": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /internal/v1/paginas/publicadas?dominio=.",
        "description": "GET /internal/v1/paginas/publicadas?dominio=.\n\nOnde é usada: chamado pelo `asender_runtime` para montar `/sitemap.xml`.\n\nEfeitos: uma leitura. Devolve só slug e data: o sitemap não precisa de título nem template, e cada campo a mais seria dado do cliente saindo por uma rota que qualquer visitante alcança pelo runtime. Domínio sem página responde lista VAZIA, e não 404: \"este host não tem página publicada\" é uma resposta sobre o mundo, e o runtime precisa dela para devolver um sitemap vazio em vez de um erro.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hi.PaginasDoDominio",
        "x-asender-fonte": "codigo"
      }
    },
    "/internal/v1/tls/authorize": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /internal/v1/tls/authorize?host=.",
        "description": "GET /internal/v1/tls/authorize?host=.\n\nOnde é usada: o servidor de borda, no meio do handshake TLS.\n\nEfeitos: uma consulta. # Fail-closed, e a resposta é um booleano Host desconhecido é `false` — negar é a resposta correta. Falha nossa é 503, e NÃO `false`: o servidor de borda tem de distinguir \"não autorizado\" de \"não consegui decidir\", senão um soluço de banco viraria recusa de certificado para domínio legítimo. A resposta não carrega tenant nem token. Quem monta o handshake não precisa disso, e devolvê-lo daria a quem sonda um mapa de quais domínios pertencem à plataforma.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hd.AutorizarTLS",
        "x-asender-fonte": "codigo"
      }
    },
    "/internal/v1/paginas/{id}/cliques": {
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /internal/v1/paginas/{id}/cliques.",
        "description": "POST /internal/v1/paginas/{id}/cliques.\n\nOnde é usada: descarga periódica do contador em memória do `asender_runtime`.\n\nEfeitos: uma escrita por destino, somando ao que já existe. # Soma, e não atribui O corpo traz o DELTA do lote, nunca um total. Dois processos do runtime descarregando ao mesmo tempo somam certo; com atribuição, o último a escrever apagaria a contagem do outro — e o relatório mostraria menos visitas do que houve, sem nada indicar a perda. # Teto de destinos no corpo O mapa vem da rede. Sem teto, um lote com um milhão de chaves viraria um milhão de `INSERT` numa transação só. O teto é o mesmo do roteamento (20 destinos), porque é o máximo que uma página pode ter.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hi.Contagens",
        "x-asender-fonte": "codigo"
      }
    },
    "/internal/v1/redirects": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /internal/v1/redirects?dominio=&slug=.",
        "description": "GET /internal/v1/redirects?dominio=&slug=.\n\nOnde é usada: chamado pelo `asender_runtime` a cada clique.\n\nEfeitos: uma leitura. Não devolve o `tenant_id`: quem serve o redirect não precisa saber de quem ele é, e devolvê-lo daria a um visitante um mapa de qual conta encurta o quê.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hi.Redirect",
        "x-asender-fonte": "codigo"
      }
    },
    "/internal/v1/redirects/cliques": {
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /internal/v1/redirects/cliques.",
        "description": "POST /internal/v1/redirects/cliques.\n\nOnde é usada: descarga periódica do contador em memória do runtime.\n\nEfeitos: uma escrita por link. SOMA, e não atribui: dois processos do runtime descarregando ao mesmo tempo somam certo; com atribuição, o último a escrever apagaria a contagem do outro.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hi.CliquesDeRedirect",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/dominios.",
        "description": "GET /v1/dominios.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hp.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/dominios.",
        "description": "POST /v1/dominios.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hp.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/paginas/{id}/experimento.",
        "description": "GET /v1/paginas/{id}/experimento.\n\nOnde é usada: tela de A/B do painel.\n\nEfeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: \"esta página não tem teste\" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hp.Obter",
        "x-asender-fonte": "codigo"
      },
      "patch": {
        "tags": [
          "pages"
        ],
        "summary": "PATCH /v1/paginas/{id} — renomear, mudar o endereço público ou o domínio.",
        "description": "PATCH /v1/paginas/{id} — renomear, mudar o endereço público ou o domínio.\n\nOnde é usada: tela de páginas do painel (renomear) e configurações da página.\n\nEfeitos: uma escrita. # Só o que veio é tocado `nil` é \"não mandado\" e `&\"\"` é \"mandado vazio\". Sem essa distinção, editar o título apagaria o endereço público da página — e ela sairia do ar sem ninguém ter pedido. # Validação IGUAL à da criação O slug e o título passam pelas mesmas regras do `Criar`. Um PATCH mais frouxo seria a porta dos fundos: bastaria criar válido e editar para inválido para pôr no ar um endereço que a criação recusa.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hp.Editar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}/versoes": {
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/paginas/{id}/versoes.",
        "description": "POST /v1/paginas/{id}/versoes.\n\nOnde é usada: botão salvar do editor.\n\nEfeitos: cria uma versão.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hp.Salvar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}/publicar": {
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/paginas/{id}/publicar.",
        "description": "POST /v1/paginas/{id}/publicar.\n\nOnde é usada: botão publicar.\n\nEfeitos: move o ponteiro da página; o visitante passa a ver esta versão.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hp.Publicar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}/experimento": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/paginas/{id}/experimento.",
        "description": "GET /v1/paginas/{id}/experimento.\n\nOnde é usada: tela de A/B do painel.\n\nEfeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: \"esta página não tem teste\" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hx.Obter",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}/experimento/auto-stop": {
      "put": {
        "tags": [
          "pages"
        ],
        "summary": "PUT /v1/paginas/{id}/experimento/auto-stop.",
        "description": "PUT /v1/paginas/{id}/experimento/auto-stop.\n\nOnde é usada: tela de A/B.\n\nEfeitos: uma escrita. Aceito com o teste RODANDO, ao contrário das variantes: apertar o critério não reatribui visitante nem invalida amostra — só muda quando o job pode concluir.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hx.DefinirAutoStop",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}/variantes": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/paginas/{id}/variantes.",
        "description": "GET /v1/paginas/{id}/variantes.\n\nOnde é usada: tela de A/B.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hx.ListarVariantes",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/paginas/{id}/variantes.",
        "description": "POST /v1/paginas/{id}/variantes.\n\nOnde é usada: botão \"nova variante\" da tela de A/B.\n\nEfeitos: uma ou duas escritas. # O experimento nasce aqui, e em rascunho Exigir uma chamada de \"criar experimento\" antes da primeira variante daria ao operador um objeto vazio que não mede nada e que ele teria de lembrar de apagar. A primeira variante cria o teste.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hx.CriarVariante",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}/variantes/{varianteID}": {
      "put": {
        "tags": [
          "pages"
        ],
        "summary": "PUT /v1/paginas/{id}/variantes/{varianteID}.",
        "description": "PUT /v1/paginas/{id}/variantes/{varianteID}.\n\nOnde é usada: ajustar peso ou versão na tela de A/B.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hx.EditarVariante",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "pages"
        ],
        "summary": "DELETE /v1/paginas/{id}/variantes/{varianteID}.",
        "description": "DELETE /v1/paginas/{id}/variantes/{varianteID}.\n\nOnde é usada: tela de A/B.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hx.ApagarVariante",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}/ab/promover": {
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/paginas/{id}/ab/promover.",
        "description": "POST /v1/paginas/{id}/ab/promover.\n\nOnde é usada: botão \"promover vencedora\" e botão \"iniciar teste\" da tela de A/B.\n\nEfeitos: uma escrita. # Duas operações na mesma rota, e por quê O lynz expõe `POST /v1/pages/{id}/ab/promote`, sem rota de iniciar — o teste começa ao ser promovido a \"rodando\". Manter uma rota só preserva a superfície do lynz, e o corpo diz qual das duas transições é pedida. # Iniciar EXIGE o experimento válido É o único ponto onde o rascunho inconsistente é barrado, e é o ponto certo: pesos que não somam 100 deixariam uma faixa do tráfego sem dono, e `Escolher` devolveria a última variante para essa fatia — um viés silencioso que o número final não denuncia.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hx.Promover",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}/roteamento": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/paginas/{id}/experimento.",
        "description": "GET /v1/paginas/{id}/experimento.\n\nOnde é usada: tela de A/B do painel.\n\nEfeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: \"esta página não tem teste\" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Obter",
        "x-asender-fonte": "codigo"
      },
      "put": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/paginas/{id}/versoes.",
        "description": "POST /v1/paginas/{id}/versoes.\n\nOnde é usada: botão salvar do editor.\n\nEfeitos: cria uma versão.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Salvar",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "pages"
        ],
        "summary": "DELETE /v1/dominios/{id}.",
        "description": "DELETE /v1/dominios/{id}.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser \"desverificado\" por outro caminho sairia do ar sem ninguém entender por quê.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/paginas/{id}/cliques": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/redirects/{id}/cliques.",
        "description": "GET /v1/redirects/{id}/cliques.\n\nOnde é usada: relatório da tela de links curtos.\n\nEfeitos: uma leitura. Devolve o CONTADOR, e não uma lista de eventos: a pergunta é sempre \"quantos\", nunca \"quais\". Uma linha por clique transformaria o caminho mais quente do redirect num INSERT por requisição.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Cliques",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/midias": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/dominios.",
        "description": "GET /v1/dominios.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "mds.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/midias (multipart, campo `arquivo`).",
        "description": "POST /v1/midias (multipart, campo `arquivo`).\n\nOnde é usada: botão de upload do editor.\n\nEfeitos: escreve no disco e no banco. # A ordem é DISCO e depois BANCO Se o disco falhar, nada foi gravado e o erro é honesto. A ordem inversa deixaria uma linha apontando para um arquivo inexistente, e o visitante receberia erro numa página publicada. O caso duplicado não grava nada: a mídia já está lá, com os mesmos bytes.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "mds.Upload",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/midias/de-url": {
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/midias/de-url.",
        "description": "POST /v1/midias/de-url.\n\nOnde é usada: colar o endereço de uma imagem no editor.\n\nEfeitos: uma requisição a servidor de terceiro; escreve no disco e no banco. # Esta é a rota mais perigosa do serviço Ela faz o SERVIDOR buscar um endereço que o USUÁRIO escolheu — a definição de SSRF. Sem as guardas, `http://169.254.169.254/latest/meta-data/` devolveria as credenciais da instância, e `http://redis-interno:6379/` alcançaria um serviço que nunca deveria ver tráfego de fora. Toda a defesa está em `domain/midia`: allowlist de esquema e porta, recusa de IP interno, conferência REPETIDA no momento de discar (contra DNS rebinding), zero redirecionamento e teto de tamanho durante a leitura.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "mds.DeURL",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/midias/{id}": {
      "delete": {
        "tags": [
          "pages"
        ],
        "summary": "DELETE /v1/dominios/{id}.",
        "description": "DELETE /v1/dominios/{id}.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser \"desverificado\" por outro caminho sairia do ar sem ninguém entender por quê.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "mds.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/public/midias/{id}": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET e HEAD /v1/public/midias/{id}.",
        "description": "GET e HEAD /v1/public/midias/{id}.\n\nOnde é usada: a tag `<img>` de uma landing publicada.\n\nEfeitos: abre e transmite o arquivo. # Sem sessão, e por isso com cuidado extra O id é o único parâmetro, e ele é resolvido no BANCO antes de tocar o disco: o caminho do arquivo nunca é montado com o que veio da URL. Path traversal não tem por onde entrar — e o `armazenamento` recusa id fora do formato de qualquer jeito, como segunda linha. # `X-Content-Type-Options: nosniff` é obrigatório aqui O tipo é o DETECTADO no upload, e a allowlist só tem imagem. Sem `nosniff`, o navegador pode reinterpretar o conteúdo e executar como outra coisa — e a allowlist de tipos perderia o sentido no último metro.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "mds.Publica",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/dominios": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/dominios.",
        "description": "GET /v1/dominios.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hd.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/dominios.",
        "description": "POST /v1/dominios.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hd.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/dominios/{id}/verificar": {
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/dominios/{id}/verificar.",
        "description": "POST /v1/dominios/{id}/verificar.\n\nOnde é usada: botão \"verificar\" da tela de domínios.\n\nEfeitos: uma consulta DNS e uma escrita. # As duas falhas têm respostas DIFERENTES \"Não consegui perguntar ao DNS\" é 503 com \"tente de novo\"; \"perguntei e o registro não está lá\" é 422 com \"publique o registro\". Colapsar as duas faria o cliente ficar publicando um registro que já está lá. # E o resultado é GRAVADO nos dois casos Sem isso, quem tenta e falha vê a tela exatamente igual à de antes de tentar — e não tem como saber se o sistema chegou a olhar.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hd.Verificar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/dominios/{id}": {
      "delete": {
        "tags": [
          "pages"
        ],
        "summary": "DELETE /v1/dominios/{id}.",
        "description": "DELETE /v1/dominios/{id}.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser \"desverificado\" por outro caminho sairia do ar sem ninguém entender por quê.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hd.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/configuracoes/marca": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/paginas/{id}/experimento.",
        "description": "GET /v1/paginas/{id}/experimento.\n\nOnde é usada: tela de A/B do painel.\n\nEfeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: \"esta página não tem teste\" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hm.Obter",
        "x-asender-fonte": "codigo"
      },
      "put": {
        "tags": [
          "pages"
        ],
        "summary": "PUT /v1/configuracoes/marca — os quatro campos de uma vez.",
        "description": "PUT /v1/configuracoes/marca — os quatro campos de uma vez.\n\nOnde é usada: tela de marca do painel.\n\nEfeitos: uma escrita; muda o que o visitante vê na próxima visita. PUT e não PATCH: são quatro campos que a tela mostra e salva juntos, e campo vazio APAGA aquele elemento. Um PATCH criaria a pergunta \"como apago o logo?\", cuja resposta seria um campo especial que ninguém acerta na primeira leitura.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hm.Definir",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/redirects": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/dominios.",
        "description": "GET /v1/dominios.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/dominios.",
        "description": "POST /v1/dominios.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/redirects/{id}": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/paginas/{id}/experimento.",
        "description": "GET /v1/paginas/{id}/experimento.\n\nOnde é usada: tela de A/B do painel.\n\nEfeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: \"esta página não tem teste\" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Obter",
        "x-asender-fonte": "codigo"
      },
      "put": {
        "tags": [
          "pages"
        ],
        "summary": "PUT /v1/formularios/{id}.",
        "description": "PUT /v1/formularios/{id}.\n\nOnde é usada: edição.\n\nEfeitos: escreve a linha.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Atualizar",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "pages"
        ],
        "summary": "DELETE /v1/dominios/{id}.",
        "description": "DELETE /v1/dominios/{id}.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser \"desverificado\" por outro caminho sairia do ar sem ninguém entender por quê.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/redirects/{id}/cliques": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/redirects/{id}/cliques.",
        "description": "GET /v1/redirects/{id}/cliques.\n\nOnde é usada: relatório da tela de links curtos.\n\nEfeitos: uma leitura. Devolve o CONTADOR, e não uma lista de eventos: a pergunta é sempre \"quantos\", nunca \"quais\". Uma linha por clique transformaria o caminho mais quente do redirect num INSERT por requisição.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hr.Cliques",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/ia/gerar-pagina": {
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/paginas/gerar — manda o pedido ao provedor, saneia a saída e devolve o template.",
        "description": "POST /v1/paginas/gerar — manda o pedido ao provedor, saneia a saída e devolve o template.\n\nOnde é usada: botão \"gerar com IA\" do editor.\n\nEfeitos: uma chamada externa; NÃO grava nada. # Não grava: devolve o rascunho Gravar direto faria o modelo publicar na conta do cliente. O que sai daqui é uma sugestão que a pessoa vê no editor e salva se quiser — a versão continua nascendo do gesto dela. # Sem provedor, a rota DEGRADA 503 e não 500: é configuração ausente, e a distinção separa \"o produto não tem essa função ligada\" de \"o produto quebrou\". O resto do serviço segue de pé — nenhuma página deixa de ser servida porque não há chave de IA (piso 10).",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hg.Gerar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/formularios": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/dominios.",
        "description": "GET /v1/dominios.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hf.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "pages"
        ],
        "summary": "POST /v1/dominios.",
        "description": "POST /v1/dominios.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hf.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/formularios/{id}": {
      "get": {
        "tags": [
          "pages"
        ],
        "summary": "GET /v1/paginas/{id}/experimento.",
        "description": "GET /v1/paginas/{id}/experimento.\n\nOnde é usada: tela de A/B do painel.\n\nEfeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: \"esta página não tem teste\" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hf.Obter",
        "x-asender-fonte": "codigo"
      },
      "put": {
        "tags": [
          "pages"
        ],
        "summary": "PUT /v1/formularios/{id}.",
        "description": "PUT /v1/formularios/{id}.\n\nOnde é usada: edição.\n\nEfeitos: escreve a linha.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hf.Atualizar",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "pages"
        ],
        "summary": "DELETE /v1/dominios/{id}.",
        "description": "DELETE /v1/dominios/{id}.\n\nOnde é usada: tela de domínios.\n\nEfeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser \"desverificado\" por outro caminho sairia do ar sem ninguém entender por quê.",
        "x-asender-servico": "asender_pages",
        "x-asender-handler": "hf.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/forms/{formID}/submissions": {
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline.",
        "description": "autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline.\n\nOnde é usada: rota autenticada do runtime.\n\nEfeitos: os do pipeline (banco, outbox). O tenant vem do CONTEXTO, posto pelo middleware que validou a API key — nunca do corpo (§37: confiar em tenant_id do cliente). É isso que faz a key de um tenant não alcançar o form de outro.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Captura.API",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/leads": {
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline.",
        "description": "autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline.\n\nOnde é usada: rota autenticada do runtime.\n\nEfeitos: os do pipeline (banco, outbox). O tenant vem do CONTEXTO, posto pelo middleware que validou a API key — nunca do corpo (§37: confiar em tenant_id do cliente). É isso que faz a key de um tenant não alcançar o form de outro.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Captura.API",
        "x-asender-fonte": "codigo"
      }
    },
    "/f/{formID}": {
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "valida a ORIGEM contra os domínios verificados do tenant, ecoa os cabeçalhos de CORS e chama o mesmo pipeline.",
        "description": "valida a ORIGEM contra os domínios verificados do tenant, ecoa os cabeçalhos de CORS e chama o mesmo pipeline.\n\nOnde é usada: rota pública, chamada de outro domínio pelo navegador.\n\nEfeitos: os do pipeline. Aqui não há API key: quem chama é o navegador do visitante, e uma key no snippet seria segredo no cliente (§13.4). A defesa é a allowlist de origem, verificada NO SERVIDOR — o header `Origin` é posto pelo navegador e não pode ser forjado por script da própria página.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Captura.Snippet",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/analytics/overview": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/analytics/overview?pagina=&de=&ate=.",
        "description": "GET /v1/analytics/overview?pagina=&de=&ate=.\n\nOnde é usada: tela inicial de analytics.\n\nEfeitos: duas leituras. Funil e atribuição juntos porque a tela mostra os dois lado a lado, e duas requisições poderiam ler janelas diferentes — o total da atribuição não fecharia com o topo do funil, e o número \"errado\" seria o certo.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Analytics.Overview",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/analytics/breakdown": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/analytics/breakdown?dimensao=utm_source&…",
        "description": "GET /v1/analytics/breakdown?dimensao=utm_source&…\n\nOnde é usada: tela de origens.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Analytics.Breakdown",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/analytics/events": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/analytics/events?pagina=&limite=.",
        "description": "GET /v1/analytics/events?pagina=&limite=.\n\nOnde é usada: tela de depuração da instrumentação.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Analytics.Eventos",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/analytics/session/{id}": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/analytics/session/{id}.",
        "description": "GET /v1/analytics/session/{id}.\n\nOnde é usada: tela de sessão — o que aquela visita fez, em ordem.\n\nEfeitos: uma leitura. Sessão de outra conta responde lista VAZIA, e não 404: o id de sessão é opaco e gerado no browser, e distinguir \"não existe\" de \"não é sua\" transformaria a rota num oráculo de existência de sessão alheia.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Analytics.Sessao",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/analytics/traffic-quality": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/analytics/traffic-quality.",
        "description": "GET /v1/analytics/traffic-quality.\n\nOnde é usada: tela de qualidade — \"esse tráfego pago é gente?\".\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Analytics.Qualidade",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/analytics/ab": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/analytics/ab?pagina=.",
        "description": "GET /v1/analytics/ab?pagina=.\n\nOnde é usada: tela do experimento.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Analytics.AB",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/analytics/heatmap": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/analytics/heatmap?pagina=&lado=.",
        "description": "GET /v1/analytics/heatmap?pagina=&lado=.\n\nOnde é usada: tela de mapa de calor.\n\nEfeitos: uma leitura. # As posições são PORCENTAGEM, e não pixel Pixel absoluto é da tela de quem clicou: o mesmo botão sai em x=320 no celular e x=980 no monitor. A célula em porcentagem compara telas diferentes — que é a única pergunta que um mapa de calor responde.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Analytics.MapaDeCalor",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/analytics/live": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/analytics/live — sessões e eventos da janela curta.",
        "description": "GET /v1/analytics/live — sessões e eventos da janela curta.\n\nOnde é usada: cabeçalho da tela ao vivo, no primeiro carregamento.\n\nEfeitos: uma leitura. Existe além do stream porque quem abre a tela precisa ver ALGO antes do primeiro evento chegar. Sem isso, uma conta com movimento baixo mostraria tela vazia por minutos e pareceria quebrada.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.AoVivo.Agora",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/analytics/live/stream": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/analytics/live/stream — Server-Sent Events.",
        "description": "GET /v1/analytics/live/stream — Server-Sent Events.\n\nOnde é usada: tela ao vivo.\n\nEfeitos: mantém uma conexão aberta e uma inscrição no hub. # SSE, e não WebSocket O fluxo é de mão única (servidor → tela) e o cliente é um navegador. SSE atravessa proxy comum, reconecta sozinho e cabe em quatro linhas de JS; WebSocket traria um protocolo inteiro para carregar dado que só desce.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.AoVivo.Stream",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/replay/sessions": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/replay/sessions?limite=N.",
        "description": "GET /v1/replay/sessions?limite=N.\n\nOnde é usada: tela de replay.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Replay.Sessoes",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/replay/sessions/{id}/events": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/replay/sessions/{id}/events.",
        "description": "GET /v1/replay/sessions/{id}/events.\n\nOnde é usada: player da tela de replay.\n\nEfeitos: uma leitura. Sessão de outra conta responde lista VAZIA, e não 404: o id é opaco e gerado no browser, e distinguir \"não existe\" de \"não é sua\" transformaria a rota num oráculo de existência de sessão alheia.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Replay.EventosDaSessao",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/pixels": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/pixels.",
        "description": "GET /v1/pixels.\n\nOnde é usada: tela de pixels.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.Listar",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "POST /v1/pixels.",
        "description": "POST /v1/pixels.\n\nOnde é usada: tela de pixels.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.Criar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/pixels/{id}": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/pixels/{id}.",
        "description": "GET /v1/pixels/{id}.\n\nOnde é usada: tela de detalhe do pixel.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.Obter",
        "x-asender-fonte": "codigo"
      },
      "patch": {
        "tags": [
          "runtime"
        ],
        "summary": "PATCH /v1/pixels/{id}.",
        "description": "PATCH /v1/pixels/{id}.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma escrita. PATCH parcial de verdade: campo ausente não é tocado. Sem isso, editar só o nome DESATIVARIA o pixel — e a coleta pararia sem ninguém ter pedido.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.Editar",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "runtime"
        ],
        "summary": "DELETE /v1/pixels/{id}.",
        "description": "DELETE /v1/pixels/{id}.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma escrita; a coleta daquele pixel para.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.Apagar",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/pixels/{id}/domains": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/pixels/{id}/domains.",
        "description": "GET /v1/pixels/{id}/domains.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.ListarDominios",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "POST /v1/pixels/{id}/domains.",
        "description": "POST /v1/pixels/{id}/domains.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma escrita — e, a partir dela, o `/collect` ecoa CORS para aquele host.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.AcrescentarDominio",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/pixels/{id}/domains/{domainId}": {
      "patch": {
        "tags": [
          "runtime"
        ],
        "summary": "PATCH /v1/pixels/{id}/domains/{domainId}.",
        "description": "PATCH /v1/pixels/{id}/domains/{domainId}.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.TrocarDominio",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "runtime"
        ],
        "summary": "DELETE /v1/pixels/{id}/domains/{domainId}.",
        "description": "DELETE /v1/pixels/{id}/domains/{domainId}.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma escrita — a origem para de coletar na hora.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.RemoverDominio",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/pixels/{id}/conversions": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /v1/pixels/{id}/conversions.",
        "description": "GET /v1/pixels/{id}/conversions.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma leitura.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.ListarConversoes",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "POST /v1/pixels/{id}/conversions.",
        "description": "POST /v1/pixels/{id}/conversions.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.CriarConversao",
        "x-asender-fonte": "codigo"
      }
    },
    "/v1/pixels/{id}/conversions/{cid}": {
      "patch": {
        "tags": [
          "runtime"
        ],
        "summary": "PATCH /v1/pixels/{id}/conversions/{cid}.",
        "description": "PATCH /v1/pixels/{id}/conversions/{cid}.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.EditarConversao",
        "x-asender-fonte": "codigo"
      },
      "delete": {
        "tags": [
          "runtime"
        ],
        "summary": "DELETE /v1/pixels/{id}/conversions/{cid}.",
        "description": "DELETE /v1/pixels/{id}/conversions/{cid}.\n\nOnde é usada: tela de detalhe.\n\nEfeitos: uma escrita.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pixels.RemoverConversao",
        "x-asender-fonte": "codigo"
      }
    },
    "/pixel.js": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "GET /pixel.js.",
        "description": "GET /pixel.js.\n\nOnde é usada: toda página publicada, uma vez por visita (depois é cache).\n\nEfeitos: escreve o script. Cache de 1 hora com ETag: o script muda com o deploy, e revalidar de hora em hora custa um 304. `immutable` seria errado — o conteúdo muda no mesmo caminho.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Coleta.Script",
        "x-asender-fonte": "codigo"
      }
    },
    "/collect": {
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "POST /collect — abre a credencial, normaliza cada evento e publica.",
        "description": "POST /collect — abre a credencial, normaliza cada evento e publica.\n\nOnde é usada: rota pública, a de maior volume do sistema depois da própria página.\n\nEfeitos: publica no broker; NUNCA escreve no banco. # Responde 204 quase sempre, e isso é deliberado Credencial inválida é 403 (o cliente precisa saber que aquele HTML está velho). Fora isso — evento inválido, tipo desconhecido, lote parcialmente ruim — a resposta é 204 e o que dava para aproveitar foi publicado. O pixel roda no browser de terceiros: transformar um evento malformado em erro visível não conserta nada e enche o console de quem só quer ver a página.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Coleta.Coletar",
        "x-asender-fonte": "codigo"
      }
    },
    "/replay": {
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "POST /replay — abre a credencial, saneia e publica.",
        "description": "POST /replay — abre a credencial, saneia e publica.\n\nOnde é usada: rota pública, chamada pelo pixel a cada poucos segundos de uma sessão gravada.\n\nEfeitos: publica no broker; NUNCA escreve no banco. Responde 204 quase sempre, como o `/collect`: credencial inválida é 403 (o HTML está velho), e o resto é aproveitado. O pixel roda no browser de terceiros, e transformar um lote parcialmente ruim em erro visível não conserta nada.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Replay.Gravar",
        "x-asender-fonte": "codigo"
      }
    },
    "/r/{slug}": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "resolve host+caminho na página publicada e devolve o HTML renderizado.",
        "description": "resolve host+caminho na página publicada e devolve o HTML renderizado.\n\nOnde é usada: rota curinga do runtime, a de maior tráfego do sistema.\n\nEfeitos: consulta a fonte; escreve a resposta. 404 quando não há página: o host pode apontar para nós sem ter página naquele caminho, e devolver erro de servidor nesse caso confundiria monitoramento com tráfego normal de quem digitou a URL errada.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Redirect.Servir",
        "x-asender-fonte": "codigo"
      }
    },
    "/robots.txt": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "libera a indexação e aponta o sitemap do MESMO host.",
        "description": "libera a indexação e aponta o sitemap do MESMO host.\n\nOnde é usada: rota pública.\n\nEfeitos: nenhum. # Por que liberar, e não bloquear A página existe para ser achada: é landing page de campanha, e quem a publica quer tráfego. Um `Disallow: /` como padrão transformaria uma decisão de produto (\"esta página não deve ser indexada\") em comportamento da plataforma — e o cliente descobriria meses depois, sem entender por que o anúncio orgânico nunca apareceu. O sitemap é apontado com o HOST da requisição, e não com um domínio configurado: cada cliente tem o dele, e um endereço fixo aqui mandaria o buscador de todo mundo para o sitemap de um só.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.SEO.Robots",
        "x-asender-fonte": "codigo"
      }
    },
    "/sitemap.xml": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "lista as páginas PUBLICADAS daquele host.",
        "description": "lista as páginas PUBLICADAS daquele host.\n\nOnde é usada: rota pública.\n\nEfeitos: uma leitura no `asender_pages`. # Host sem página responde XML VAZIO, e não 404 Um 404 no sitemap faz o buscador registrar erro e tentar de novo; um documento vazio diz \"não há nada para indexar aqui\", que é a verdade. E o host que ainda não tem página publicada é o caso mais comum logo depois de alguém cadastrar um domínio. # Rascunho não entra A consulta do `asender_pages` filtra por versão publicada. Listar rascunho entregaria ao buscador o endereço de uma página que o cliente ainda não quis mostrar — e o buscador não esquece.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.SEO.Sitemap",
        "x-asender-fonte": "codigo"
      }
    },
    "/{slug}": {
      "get": {
        "tags": [
          "runtime"
        ],
        "summary": "resolve host+caminho na página publicada e devolve o HTML renderizado.",
        "description": "resolve host+caminho na página publicada e devolve o HTML renderizado.\n\nOnde é usada: rota curinga do runtime, a de maior tráfego do sistema.\n\nEfeitos: consulta a fonte; escreve a resposta. 404 quando não há página: o host pode apontar para nós sem ter página naquele caminho, e devolver erro de servidor nesse caso confundiria monitoramento com tráfego normal de quem digitou a URL errada.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pagina.Servir",
        "x-asender-fonte": "codigo"
      },
      "post": {
        "tags": [
          "runtime"
        ],
        "summary": "resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e chama o MESMO pipeline dos outros dois modos.",
        "description": "resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e chama o MESMO pipeline dos outros dois modos.\n\nOnde é usada: POST no caminho da própria página.\n\nEfeitos: os do pipeline (banco, outbox). O tenant e o form vêm da PÁGINA resolvida no servidor, nunca do corpo: o form da página é HTML público, e qualquer um pode reenviá-lo com outro `form_id`. Resolver pelo host+caminho é o que impede postar no formulário de outro tenant.",
        "x-asender-servico": "asender_runtime",
        "x-asender-handler": "d.Pagina.Submeter",
        "x-asender-fonte": "codigo"
      }
    }
  },
  "components": {
    "schemas": {
      "api_ErrorEnvelope": {
        "type": "object",
        "required": [
          "Error",
          "RequestId"
        ],
        "properties": {
          "Error": {
            "type": "object",
            "required": [
              "Type",
              "Code",
              "Message"
            ],
            "properties": {
              "Type": {
                "type": "string",
                "enum": [
                  "Sender",
                  "Receiver"
                ],
                "description": "Derivado do status: 4xx→Sender, 5xx→Receiver. O SDK decide retry por aqui."
              },
              "Code": {
                "type": "string",
                "examples": [
                  "ValidationError",
                  "AuthorizationError",
                  "NotFound",
                  "TenantSuspended",
                  "NoTenant",
                  "Conflict",
                  "PayloadTooLarge",
                  "Throttling",
                  "InternalError"
                ]
              },
              "Message": {
                "type": "string"
              },
              "Details": {
                "description": "Presente só na validação SES do `/v1/*`: mapa campo → motivo. Omitido quando vazio.",
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                }
              }
            }
          },
          "RequestId": {
            "type": "string"
          }
        }
      },
      "api_RequestIdEnvelope": {
        "type": "object",
        "required": [
          "RequestId"
        ],
        "properties": {
          "RequestId": {
            "type": "string",
            "description": "Presente em TODA resposta de sucesso. Espelha o header\n`X-Request-Id`. **Divergência:** um `X-Request-Id` mandado pelo\ncliente é ecoado cru e vira o correlation id do log\n(RELATORIO-FINAL §3 item 15).\n"
          }
        }
      },
      "api_CursorPage": {
        "type": "object",
        "properties": {
          "NextCursor": {
            "type": "integer",
            "description": "Cursor da próxima página; 0 quando acabou."
          },
          "HasMore": {
            "type": "boolean"
          }
        }
      },
      "api_Channel": {
        "type": "string",
        "enum": [
          "email",
          "sms",
          "push"
        ]
      },
      "api_MessageStatus": {
        "type": "string",
        "enum": [
          "queued",
          "sending",
          "sent",
          "delivered",
          "bounced",
          "failed",
          "opened",
          "clicked"
        ]
      },
      "api_Message": {
        "type": "object",
        "description": "Projeção de listagem: SEM corpo (payload grande em lista é desperdício).",
        "properties": {
          "Id": {
            "type": "string"
          },
          "TenantId": {
            "type": "string"
          },
          "Channel": {
            "$ref": "#/components/schemas/Channel"
          },
          "Status": {
            "$ref": "#/components/schemas/MessageStatus"
          },
          "From": {
            "type": "string"
          },
          "To": {
            "type": "string"
          },
          "Subject": {
            "type": "string"
          },
          "Provider": {
            "type": [
              "string",
              "null"
            ]
          },
          "ProviderMessageId": {
            "type": [
              "string",
              "null"
            ]
          },
          "ErrorCode": {
            "type": [
              "string",
              "null"
            ]
          },
          "ErrorMessage": {
            "type": [
              "string",
              "null"
            ]
          },
          "QueuedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "SentAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "DeliveredAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "FailedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "OpenedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "ClickedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "CreatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "api_MessagePage": {
        "allOf": [
          {
            "$ref": "#/components/schemas/RequestIdEnvelope"
          },
          {
            "$ref": "#/components/schemas/CursorPage"
          },
          {
            "type": "object",
            "properties": {
              "Messages": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Message"
                }
              }
            }
          }
        ]
      },
      "api_MessageDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/RequestIdEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "Message": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/Message"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "BodyText": {
                        "type": "string"
                      },
                      "BodyHtml": {
                        "type": "string"
                      },
                      "Metadata": {
                        "type": "object",
                        "additionalProperties": true,
                        "description": "VALOR OPACO — chaves internas passam intactas (não são pascalizadas)."
                      }
                    }
                  }
                ]
              },
              "Events": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "Type": {
                      "type": "string"
                    },
                    "OccurredAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "Payload": {
                      "type": "object",
                      "additionalProperties": true,
                      "description": "Valor opaco."
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "api_SendEmailRequest": {
        "type": "object",
        "required": [
          "Source",
          "Destination",
          "Message"
        ],
        "properties": {
          "Source": {
            "type": "string",
            "format": "email"
          },
          "Destination": {
            "type": "object",
            "properties": {
              "ToAddresses": {
                "type": "array",
                "items": {
                  "type": "string",
                  "format": "email"
                }
              },
              "CcAddresses": {
                "type": "array",
                "items": {
                  "type": "string",
                  "format": "email"
                }
              },
              "BccAddresses": {
                "type": "array",
                "items": {
                  "type": "string",
                  "format": "email"
                }
              }
            },
            "description": "Ao menos um endereço somando To+Cc+Bcc."
          },
          "Message": {
            "type": "object",
            "required": [
              "Subject",
              "Body"
            ],
            "properties": {
              "Subject": {
                "type": "object",
                "properties": {
                  "Data": {
                    "type": "string",
                    "minLength": 1
                  },
                  "Charset": {
                    "type": "string"
                  }
                }
              },
              "Body": {
                "type": "object",
                "description": "Ao menos um de Text.Data ou Html.Data.",
                "properties": {
                  "Text": {
                    "type": "object",
                    "properties": {
                      "Data": {
                        "type": "string"
                      },
                      "Charset": {
                        "type": "string"
                      }
                    }
                  },
                  "Html": {
                    "type": "object",
                    "properties": {
                      "Data": {
                        "type": "string"
                      },
                      "Charset": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "ReplyToAddresses": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "email"
            }
          },
          "ReturnPath": {
            "type": "string",
            "format": "email"
          },
          "IdempotencyKey": {
            "type": "string",
            "description": "É ESTE campo que funciona, não o header."
          },
          "Tags": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          }
        }
      },
      "api_BatchResult": {
        "type": "object",
        "properties": {
          "Index": {
            "type": "integer"
          },
          "Status": {
            "type": "string",
            "enum": [
              "queued",
              "rejected",
              "failed"
            ]
          },
          "MessageId": {
            "type": "string"
          },
          "MessageIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "Error": {
            "type": "object",
            "properties": {
              "Code": {
                "type": "string"
              },
              "Message": {
                "type": "string"
              },
              "Details": {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "api_Template": {
        "type": "object",
        "properties": {
          "Id": {
            "type": "string"
          },
          "TenantId": {
            "type": "string"
          },
          "Slug": {
            "type": "string"
          },
          "Name": {
            "type": "string"
          },
          "Channel": {
            "$ref": "#/components/schemas/Channel"
          },
          "Subject": {
            "type": "string"
          },
          "BodyHtml": {
            "type": "string"
          },
          "BodyText": {
            "type": "string"
          },
          "Variables": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Valor opaco — não pascalizado."
          },
          "CreatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "UpdatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "api_ContactInput": {
        "type": "object",
        "additionalProperties": false,
        "description": "Ao menos um de Email ou Phone.",
        "properties": {
          "Email": {
            "type": "string"
          },
          "Phone": {
            "type": "string"
          },
          "Name": {
            "type": "string"
          },
          "Attributes": {
            "type": "object",
            "additionalProperties": true,
            "description": "Valor opaco."
          },
          "Subscribed": {
            "type": "boolean",
            "default": true
          }
        }
      },
      "api_Contact": {
        "type": "object",
        "properties": {
          "Id": {
            "type": "string"
          },
          "TenantId": {
            "type": "string"
          },
          "Email": {
            "type": "string"
          },
          "Phone": {
            "type": "string"
          },
          "Name": {
            "type": "string"
          },
          "Attributes": {
            "type": "object",
            "additionalProperties": true
          },
          "Subscribed": {
            "type": "boolean"
          },
          "UnsubscribedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "CreatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "UpdatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "api_ContactList": {
        "type": "object",
        "properties": {
          "Id": {
            "type": "string"
          },
          "TenantId": {
            "type": "string"
          },
          "Name": {
            "type": "string"
          },
          "Slug": {
            "type": "string"
          },
          "Description": {
            "type": "string"
          },
          "MemberCount": {
            "type": "integer"
          },
          "CreatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "UpdatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "api_Device": {
        "type": "object",
        "properties": {
          "Id": {
            "type": "string"
          },
          "TenantId": {
            "type": "string"
          },
          "ContactId": {
            "type": "string"
          },
          "Token": {
            "type": "string"
          },
          "Platform": {
            "type": "string",
            "enum": [
              "ios",
              "android",
              "web"
            ]
          },
          "Active": {
            "type": "boolean"
          },
          "LastSeenAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "CreatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "UpdatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "api_Tenant": {
        "type": "object",
        "description": "Projeção do asender-core (`tenantPayload`), pascalizada.",
        "properties": {
          "Id": {
            "type": "string"
          },
          "Name": {
            "type": "string"
          },
          "Slug": {
            "type": "string"
          },
          "Status": {
            "type": "string",
            "enum": [
              "active",
              "suspended"
            ]
          },
          "TrialEndsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "Metadata": {
            "type": "object",
            "additionalProperties": true,
            "description": "Valor opaco."
          },
          "CreatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "UpdatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "api_TenantNode": {
        "type": "object",
        "description": "Nó de árvore (`tenantNodePayload` do core), pascalizado.",
        "properties": {
          "Id": {
            "type": "string"
          },
          "Name": {
            "type": "string"
          },
          "Slug": {
            "type": "string"
          },
          "Status": {
            "type": "string"
          },
          "Kind": {
            "type": "string",
            "enum": [
              "root",
              "leaf"
            ]
          },
          "Depth": {
            "type": "integer",
            "description": "Profundidade absoluta na árvore."
          },
          "RelativeDepth": {
            "type": "integer",
            "description": "Profundidade relativa ao nó consultado — use para indentar."
          },
          "ParentId": {
            "type": [
              "string",
              "null"
            ]
          },
          "MonthlyQuota": {
            "type": [
              "integer",
              "null"
            ],
            "description": "`null` = herda."
          },
          "EffectiveQuota": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Valor que realmente vale; `null` = sem limite em nenhum ancestral."
          },
          "ChildrenCount": {
            "type": "integer"
          },
          "CreatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "UpdatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "api_Usage": {
        "type": "object",
        "properties": {
          "Period": {
            "type": "string",
            "examples": [
              "2026-07"
            ]
          },
          "EmailsSent": {
            "type": "integer"
          },
          "SmsSent": {
            "type": "integer"
          },
          "PushSent": {
            "type": "integer"
          },
          "ApiCalls": {
            "type": "integer"
          },
          "UpdatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "api_ReportCounters": {
        "type": "object",
        "description": "`Total` é o volume do período; `Queued` é BACKLOG (o que ainda não saiu).",
        "properties": {
          "Total": {
            "type": "integer"
          },
          "Queued": {
            "type": "integer"
          },
          "Sent": {
            "type": "integer"
          },
          "Delivered": {
            "type": "integer"
          },
          "Bounced": {
            "type": "integer"
          },
          "Failed": {
            "type": "integer"
          },
          "Opened": {
            "type": "integer"
          },
          "Clicked": {
            "type": "integer"
          }
        }
      },
      "api_ReportRates": {
        "type": "object",
        "description": "`Delivery = Delivered/Sent`, `Bounce = Bounced/Sent`,\n`Open = Opened/Delivered`, `Click = Clicked/Delivered`.\nDenominador zero devolve `0` (nunca `null`, nunca `NaN`).\n",
        "properties": {
          "Delivery": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "Bounce": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "Open": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "Click": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          }
        }
      },
      "api_ReportPeriod": {
        "type": "object",
        "description": "Janela efetivamente usada, já com o default aplicado. `To` é EXCLUSIVO.",
        "properties": {
          "From": {
            "type": "string",
            "format": "date-time"
          },
          "To": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "auth_ErrorEnvelope": {
        "type": "object",
        "required": [
          "Error",
          "RequestId"
        ],
        "properties": {
          "Error": {
            "type": "object",
            "required": [
              "Type",
              "Code",
              "Message"
            ],
            "properties": {
              "Type": {
                "type": "string",
                "enum": [
                  "Sender",
                  "Receiver"
                ]
              },
              "Code": {
                "type": "string",
                "examples": [
                  "InvalidRequest",
                  "InvalidInput",
                  "MissingToken",
                  "InvalidCredentials",
                  "InvalidToken",
                  "ExpiredToken",
                  "TwoFactorFailed",
                  "TwoFactorMissing",
                  "NotFound",
                  "Conflict",
                  "NotReady",
                  "InternalError"
                ]
              },
              "Message": {
                "type": "string"
              },
              "Details": {}
            }
          },
          "RequestId": {
            "type": "string"
          }
        }
      },
      "auth_User": {
        "type": "object",
        "description": "Nunca traz `password_hash` nem o segredo TOTP.",
        "properties": {
          "id": {
            "type": "string",
            "examples": [
              "usr_demo_owner"
            ]
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "name": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "auth_LoginResult": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string",
            "description": "JWT de sessão. VAZIO quando `two_factor_required`."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "user_id": {
            "type": "string"
          },
          "two_factor_required": {
            "type": "boolean"
          }
        }
      },
      "auth_UserIdBody": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "user_id"
        ],
        "properties": {
          "user_id": {
            "type": "string"
          }
        }
      },
      "auth_TwoFACodeBody": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "user_id",
          "code"
        ],
        "properties": {
          "user_id": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "description": "TOTP de 6 dígitos."
          }
        }
      }
    },
    "securitySchemes": {
      "ApiKeyBearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer sk_live_...`. Prefixos aceitos: `sk_live_`,\n`sk_test_`, `sk_master_`, `pk_live_`, mínimo 16 caracteres. Validada no\n`asender-core`; tenant não-`active` → 403 `TenantSuspended`.\n\n**Alternativas aceitas e DIVERGENTES do piso da casa:** o header\n`X-Api-Key` e o parâmetro de query `?api_key=`. Credencial em query\nstring vaza para access log, Referer e histórico do browser — é dívida\nregistrada, não recomendação.\n"
      },
      "SessionBearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "Token de sessão do usuário final, emitido por `POST /v1/auth/login`."
      },
      "SessionCookie": {
        "type": "apiKey",
        "in": "cookie",
        "name": "asender_session",
        "description": "Cookie HttpOnly posto pelo dashboard. Equivalente ao Bearer de sessão."
      },
      "ServiceToken": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Asender-Svc-Token",
        "description": "`SERVICE_SECRET` **cru** (não é JWT), comparado em tempo constante —\nmesmo contrato do `asender-core` e do `asender_messages`. Exigido em\ntodo ambiente; só `SERVICE_AUTH_DISABLED=true` desliga.\n"
      }
    }
  }
}