# RiLiGar Monitors — Master Implementation Guide (LLM-Ready) > Uptime de APIs e performance web, numa máquina só. O cliente cadastra uma > URL e ganha checagem periódica, histórico, incidentes confirmados, alerta e > uma página de status pública no domínio dele. > > Este arquivo é a documentação inteira do produto — toda a API cabe em vinte > e uma rotas. Cole a URL no seu agente (Claude Code, Cursor, Windsurf, Codex, > Copilot) e peça a integração: não é preciso mais nada. > A seção 9 delimita o escopo com precisão — leia antes de inventar API. Base da API: https://monitors.manager.myinfrastructure.click Painel: https://monitors.dashboard.myinfrastructure.click Página de status (pré-visualização): https://monitors.manager.myinfrastructure.click/s/ Servidor MCP: https://monitors.manager.myinfrastructure.click/mcp — ver a seção 8. Documentação do MCP: https://myinfrastructure.click/products/monitors/llms-mcp.txt ## 0. Credenciais — leia antes de escrever código O Monitors tem uma credencial: a chave `mk_…`, enviada no header `x-api-key`. **Como pedir a chave ao usuário** — use este texto: > Preciso de uma chave de API do Monitors. Ela está no seu **painel** > (https://monitors.dashboard.myinfrastructure.click), em **Configurações**. Ela começa > com `mk_`. Sem `x-api-key`, toda rota de dados responde `401 {"error":"Faltando x-api-key."}`. Com uma chave que não existe, `403 {"error":"API key inválida."}`. A chave também é aceita em `Authorization: Bearer mk_…` — o roteador reconhece o prefixo `mk_` e trata como API key, não como JWT. O header `x-api-key` é o caminho canônico; o Bearer existe para SDKs que só sabem mandar Authorization. **Se o usuário ainda não tem conta, você se provisiona sozinho.** A `mk_` nasce de uma sessão autenticada — mas você consegue essa sessão sem navegador, pelo device flow do RiLiGar Auth. A pessoa lê um código de 8 caracteres no e-mail, informa a você, e o resto é seu: ```bash # 1. sessão (uma interação humana: o código do e-mail) POST https://auth.worker.myinfrastructure.click/auth/code/start {"email":"..."} POST https://auth.worker.myinfrastructure.click/auth/code/verify {"email":"...","code":"ABCD-EFGH"} → JWT # 2. a conta e a chave NASCEM SOZINHAS — não há "projeto" nem "instância" aqui GET https://monitors.manager.myinfrastructure.click/me -H "authorization: Bearer " # → {"user":{"id":"...","name":"...","email":"..."},"apiKey":"mk_...","mode":"auth"} ``` `GET /me` é a **única** rota que fala JWT. A partir dela a `mk_` substitui o token em todas as outras. A chave é recuperável — `GET /me` de novo devolve a mesma. Um token presente e inválido é `401`, nunca um rebaixamento silencioso. O percurso completo dos produtos, e o que a RiLiGar é: https://myinfrastructure.click/llms.txt Se o usuário já tem a chave, peça — é mais rápido. Se não tem conta, use o caminho acima. O que você não pode é gerar código com `mk_xxx` de placeholder e dizer que terminou: sem credencial de verdade, **pare e peça**. O dono de um monitor é sempre derivado da credencial, nunca de um campo do corpo ou da URL — um campo seria falsificável. O filtro por dono vive dentro do SQL de toda consulta, não num `if` antes dela. ## 1. Modelo mental Um MONITOR é uma URL, um intervalo e uma sonda. Há **duas famílias**, e a escolha entre elas define quase tudo o que você vai escrever: | | `kind: "http"` | `kind: "web"` | | --- | --- | --- | | Sonda | `fetch` | Lighthouse + Chrome headless | | Pergunta que responde | "está no ar e respondendo certo?" | "quão rápida está a página?" | | Custo | ~1 KB, dezenas de ms | ~130 MB de RSS, 10–60 s | | Intervalo mínimo | 30 s | 1 hora | | Concorrência | livre | **fila serial de 1** | | Tela do painel | APIs & Uptime | Web Performance | A fila serial do `web` não é detalhe de implementação: cada auditoria sobe um Chrome, e duas simultâneas numa `shared-cpu-1x` de 1 GB competem com os pings e com o SQLite até o OOM killer derrubar a máquina inteira. Uma auditoria de cada vez, sempre. É por isso que o intervalo mínimo de `web` é 1 hora e não 30 s — o piso não é comercial, é físico. No banco existe **uma** tabela `monitors` com uma coluna `kind`, não duas tabelas paralelas: dono, URL, intervalo, pausa, tags, histórico e incidentes são compartilhados. O que difere é a sonda e a forma do `config`. A unicidade é do par **(dono, nome)**: dois clientes podem ter um monitor `checkout` cada um, sem colidir. Nome repetido na mesma conta é `409`. ## 2. Intervalos — lista fechada, não número livre ``` INTERVALOS_HTTP = [30, 60, 300, 600, 1800, 3600] segundos INTERVALOS_WEB = [3600, 21600, 43200, 86400] segundos ``` Qualquer outro valor é `400`, com a lista aceita na mensagem. Não há intervalo arbitrário: um cliente pedindo 1 s derruba tanto o alvo dele quanto esta VM. Padrão quando você omite `intervalS`: **300** para `http`, **86400** para `web`. O relógio vive só no processo primário do cluster; os workers pedem a ele por IPC. Cada monitor recebe um jitter derivado do próprio id, para que cinquenta monitores de 60 s não disparem todos no mesmo segundo. ## 3. Estados e incidentes São **quatro** estados, e a diferença entre eles é o que faz o alerta acusar a causa certa: | Estado | Significa | Conta como uptime? | | --- | --- | --- | | `up` | respondeu como esperado | sim | | `degraded` | no ar, fora do acordado (latência ou orçamento de performance) | **sim** (mas alerta) | | `down` | não respondeu, ou respondeu diferente do esperado | não | | `error` | a sonda não conseguiu medir — o Chrome nem carregou a página | não | `degraded` conta como uptime de propósito: o serviço está no ar, é a experiência que regrediu. Mas ele **abre incidente e avisa** — com régua própria, mais lenta que a da queda (**3** checagens seguidas, configurável em `config.degradedConfirmations`), porque latência oscila e um pico isolado é ruído. Configurar um orçamento de performance e nunca ser avisado ao estourá-lo é o defeito que essa régua existe para não repetir. O alerta de degradação diz **DEGRADADO**, nunca FORA, e o webhook recebe `event: "monitor.degraded"`. A distinção é operacional: "FORA" manda subir o serviço, "DEGRADADO" manda investigar performance — e anunciar uma página lenta como fora do ar leva o plantão a procurar um serviço caído que está de pé. Um `degraded` **não fecha** um incidente de queda: só um `up` limpo fecha. O acordo continua descumprido, e dizer "recuperado" para uma página que segue lenta seria mentir para quem está de plantão. `error` é distinto de `down` porque o Lighthouse devolve score `null` quando o Chrome não carrega a página (DNS morto, interstitial de certificado, conexão recusada). Tratar isso como score 0 faria o alerta dizer "LCP piorou" para um domínio que não existe, e mandaria a pessoa investigar a coisa errada. **A régua de confirmação.** Há DUAS, independentes. Um incidente de QUEDA abre depois de `confirmations` falhas **seguidas** — padrão **2**, configurável por monitor em `config.confirmations` — e fecha na **primeira** checagem boa. Uma falha isolada não acorda ninguém; segurar a notícia boa não protege ninguém. A régua da DEGRADAÇÃO é separada: `degradedConfirmations`, padrão **3**. Mais lenta de propósito — queda é binária e urgente, latência oscila o tempo todo, e o que interessa nela é a tendência que se sustenta. O uptime sai sempre de `checks`, nunca da tabela de incidentes: duas fontes divergiriam e ninguém saberia em qual acreditar. ## 4. `config` — o que cada família aceita `config` é um objeto livre no corpo, interpretado pela sonda da família. Campos desconhecidos são ignorados, não recusados. ### `kind: "http"` | Campo | Padrão | O quê | | --- | --- | --- | | `method` | `"GET"` | método da requisição | | `expectStatus` | `200` | status exato esperado; outro → `down` | | `expectBody` | — | string que o corpo precisa conter; ausente → `down` | | `headers` | — | objeto de headers extras | | `body` | — | corpo (ignorado em GET/HEAD) | | `timeoutMs` | `10000` | teto absoluto de **30000**, mesmo que peça mais | | `degradedMs` | — | acima disso → `degraded` (conta como uptime, mas alerta) | | `followRedirects` | `false` | sem isto o redirect é **manual**: um 301 é `down` | | `confirmations` | `2` | falhas seguidas para abrir incidente | | `degradedConfirmations` | `3` | checagens seguidas fora do acordado para alertar degradação | Redirect manual por padrão porque seguir automaticamente esconde que o alvo mudou de lugar, e um 301 para uma página de erro contaria como sucesso. **Headers que a sonda recusa**, silenciosamente: `host`, `content-length`, `connection`, `transfer-encoding`, e tudo que comece com `x-forwarded`, `fly-` ou `cf-`. Um `host` reescrito faria a requisição sair para um vizinho na rede interna com o Host de outro alvo. A sonda sempre manda `user-agent: RiLiGar-Monitors/1.0`. O corpo da resposta só é lido quando há `expectBody`, e mesmo assim só os primeiros 64 KB. Sem `expectBody` o stream é cancelado: baixar megabytes por minuto não diz nada sobre disponibilidade. ### `kind: "web"` | Campo | Padrão | O quê | | --- | --- | --- | | `device` | `"mobile"` | `"desktop"` desliga a emulação de tela | | `throttling` | simulado | `"none"` mede sem throttling | | `maxLcpMs` | — | LCP acima disso → `degraded` | | `maxTbtMs` | — | TBT acima disso → `degraded` | | `maxCls` | — | CLS acima disso → `degraded` | | `minScore` | — | score de performance abaixo disso → `degraded` | | `confirmations` | `2` | falhas seguidas para abrir incidente | | `degradedConfirmations` | `3` | checagens seguidas fora do acordado para alertar degradação | Categorias auditadas: `performance`, `best-practices`, `seo`. O resumo numérico que volta em `detail`: ```json { "scores": { "performance": 87, "bestPractices": 92, "seo": 100 }, "vitals": { "lcp": 2410.5, "fcp": 1120.0, "cls": 0.02, "tbt": 180.0, "si": 2900.0, "tti": 3400.0, "ttfb": 220.0 } } ``` Teto de 90 s por auditoria (`MON_LH_TIMEOUT_MS`): um Chrome preso numa página que nunca carrega seguraria a fila serial inteira — e a fila é de todos. O relatório completo (as oportunidades, os requests, o LHR resumido) vive separado, em `GET /monitors/:id/report/:checkId`, e tem retenção própria de **7 dias** — cada um pesa ~130 KB contra ~100 bytes de uma linha de checagem. ## 5. Rotas Todas exigem `x-api-key: mk_…`, exceto `GET /status`, `GET /` e as páginas de status públicas (`/s/:slug` e o domínio do cliente). | Método | Rota | O quê | | --- | --- | --- | | GET | `/me` | troca o JWT do painel pela `mk_` da conta | | GET | `/overview` | o Command Center: tudo numa chamada (`?horas=`) | | POST | `/monitors` | cria | | GET | `/monitors` | lista (`?kind=http\|web`) | | GET | `/monitors/:id` | detalhe + stats comparados + série (`?horas=`) | | PATCH | `/monitors/:id` | edita, inclusive pausar | | DELETE | `/monitors/:id` | remove (cascata: checagens, relatórios, incidentes) | | POST | `/monitors/:id/check` | checa **agora**, fora do relógio | | GET | `/monitors/:id/checks` | histórico cru (`?limit=`, teto 500) | | GET | `/monitors/:id/episodes` | estados agrupados em episódios (`?horas=`) | | GET | `/monitors/:id/series` | série para o gráfico (`?horas=`) | | GET | `/monitors/:id/report/:checkId` | relatório Lighthouse completo | | GET | `/incidents` | incidentes da conta (`?monitorId=`, `?limit=`) | | GET | `/channels` | canais de alerta (mascarados) | | POST | `/channels` | cria canal (`webhook`, `email`, `discord`, `telegram`) | | POST | `/channels/:id/test` | dispara no canal a MESMA mensagem de uma queda real | | DELETE | `/channels/:id` | remove canal | | GET | `/status-pages` | lista as páginas públicas | | POST | `/status-pages` | cria página | | GET | `/status-pages/:id` | detalhe + `alvoCNAME` | | PATCH | `/status-pages/:id` | edita, inclusive branding | | DELETE | `/status-pages/:id` | remove (revoga o certificado antes) | | PUT | `/status-pages/:id/monitors` | define quais monitores a página mostra | | GET | `/status-pages/:id/domain` | verifica o certificado na Fly | | POST | `/status-pages/:id/domain` | reivindica um domínio próprio | | DELETE | `/status-pages/:id/domain` | solta o domínio | | GET | `/status` | saúde (público, sem chave) | | GET | `/s/:slug` | página de status pública (HTML, sem chave) | | GET | `/s/:slug/data.json` | os mesmos dados em JSON (sem chave) | Corpo máximo: **256 KB**. Acima disso, `400`. ### Criar um monitor ```bash curl -X POST https://monitors.manager.myinfrastructure.click/monitors \ -H "x-api-key: mk_…" \ -H "content-type: application/json" \ -d '{ "kind": "http", "name": "checkout", "url": "https://api.acme.com/health", "intervalS": 60, "tags": ["prod"], "config": { "expectStatus": 200, "expectBody": "\"ok\":true", "degradedMs": 800 } }' ``` `201` com o monitor criado. A URL precisa ser http(s) e **sem credenciais embutidas** — `user:senha@host` acabaria escrita em claro na listagem e nos logs, que é exatamente o vazamento que ninguém procura porque ninguém lembra que a URL tinha senha. Máximo de 10 tags; nome até 120 caracteres. Um monitor `web`: ```bash curl -X POST https://monitors.manager.myinfrastructure.click/monitors \ -H "x-api-key: mk_…" -H "content-type: application/json" \ -d '{"kind":"web","name":"landing","url":"https://acme.com", "intervalS":86400,"config":{"device":"mobile","maxLcpMs":2500,"minScore":80}}' ``` ### Checar agora ```bash curl -X POST https://monitors.manager.myinfrastructure.click/monitors/mon_…/check \ -H "x-api-key: mk_…" ``` ```json {"status":"up","ok":true,"durationMs":142,"httpStatus":200, "error":null,"detail":null,"checkId":"chk_…"} ``` A checagem manual é gravada com `source: "manual"`, distinta do `cron`: é o que permite responder "isto está falhando sozinho?" sem que um teste manual bem-sucedido mascare a resposta. Numa checagem `web`, esta chamada entra na fila serial e pode levar dezenas de segundos. ### Pausar ```bash curl -X PATCH https://monitors.manager.myinfrastructure.click/monitors/mon_… \ -H "x-api-key: mk_…" -H "content-type: application/json" \ -d '{"paused": true}' ``` Pausar desarma o relógio. Editar `intervalS` ou despausar rearma. Campos editáveis: `name`, `url`, `intervalS`, `paused`, `tags`, `config`. **`kind` não é editável** — a sonda e a forma do resultado mudariam, e o histórico ficaria com duas semânticas na mesma série. ### Overview — o Command Center ```bash curl "https://monitors.manager.myinfrastructure.click/overview?horas=24" -H "x-api-key: mk_…" ``` Uma rota só, e não o painel somando N chamadas de detalhe: com 24 monitores seriam 24 requisições, e o uptime global sairia diferente do que cada linha mostra, porque cada uma teria sido calculada num instante distinto. ```json {"janelaHoras": 24, "monitores": [ { "id":"mon_…", "kind":"http", "stats":{…}, "proximaChecagem": 1757000000000 } ], "resumo": {"total": 12, "http": {"total": 9, "online": 9, "fora": 0}, "web": {"total": 3, "scoreMedio": 88}, "uptimeGlobal": 99.87, "incidentesAbertos": 0}, "incidentes": [ … ]} ``` `uptimeGlobal` é `null` quando nenhum monitor tem checagem na janela — a média **ignora** quem não foi medido. Incluir como 100 inventaria disponibilidade que ninguém mediu, e é a diferença entre um painel honesto e um painel bonito. ### Canais de alerta ```bash curl -X POST https://monitors.manager.myinfrastructure.click/channels \ -H "x-api-key: mk_…" -H "content-type: application/json" \ -d '{"kind":"webhook","url":"https://hooks.acme.com/monitors"}' curl -X POST https://monitors.manager.myinfrastructure.click/channels \ -H "x-api-key: mk_…" -H "content-type: application/json" \ -d '{"kind":"telegram","token":"123:ABC","chatId":"-1001234567890"}' # e-mail: `emails` aceita string com separador OU lista já pronta curl -X POST https://monitors.manager.myinfrastructure.click/channels \ -H "x-api-key: mk_…" -H "content-type: application/json" \ -d '{"kind":"email","emails":"plantao@acme.com; sre@acme.com, cto@acme.com"}' curl -X POST https://monitors.manager.myinfrastructure.click/channels \ -H "x-api-key: mk_…" -H "content-type: application/json" \ -d '{"kind":"discord","url":"https://discord.com/api/webhooks/123456789/token"}' ``` São **quatro** tipos: `webhook`, `email`, `discord` e `telegram`. Nada além disso — ver a seção 9. **`email`.** O campo `emails` aceita uma string com vírgula, ponto e vírgula ou quebra de linha (Outlook e Excel usam ponto e vírgula, e é de lá que uma escala de plantão costuma ser colada), ou uma lista JSON já separada. Endereço repetido é descartado ignorando a caixa. **Máximo de 50 endereços por canal** — é o teto da Cloudflare Email Sending, e acima disso a rota responde `400` em vez de truncar em silêncio, o que deixaria alguém fora do plantão sem descobrir. Um endereço inválido no meio da lista também é `400`, com o endereço citado: nada é criado pela metade. **`discord`.** Precisa ser o endereço de webhook do PRÓPRIO Discord (`discord.com/api/webhooks//`); outro host recebe `400`. Não é frescura de validação: o corpo enviado neste kind é o formato do Discord (`content` + `embeds` com cor), e um host qualquer receberia um POST que não entende. Pelo mesmo motivo, **não use `kind: "webhook"` com uma URL do Discord** — o Discord recusa o nosso JSON genérico com 400, e o resultado é um canal que parece configurado no painel e nunca entrega nada. Onde o usuário acha essa URL, no Discord: **Configurações do Servidor → Integrações → Criar Webhook →** escolher o canal **→ Copiar URL do Webhook**. Exige a permissão **Gerenciar Webhooks** (`MANAGE_WEBHOOKS`) no servidor. Três variações da colagem são tratadas assim, e vale saber antes de "corrigir" a URL do usuário: | Forma | Resultado | | --- | --- | | `?wait=true` no fim | **aceita** — a query é descartada ao salvar | | `/api/v10/webhooks/…` | **aceita** — versão explícita é documentada | | `/slack` ou `/github` no fim | **`400` com mensagem própria** — são endpoints irmãos que esperam o corpo do Slack / o evento do GitHub | | link do canal (`/channels/…`) ou convite (`discord.gg/…`) | `400` — não é webhook | O payload que o webhook recebe: ```json {"event": "monitor.down", "monitor": {"id":"mon_…","name":"checkout","url":"https://…","kind":"http"}, "incidentId": "inc_…", "status": "down", "error": "HTTP 503, esperado 200.", "durationMs": 1204, "at": "2026-09-05T12:00:00.000Z", "text": "FORA: checkout (https://…)\nHTTP 503…\nConfirmado em 2 checagens seguidas."} ``` **Sem emoji em nenhum canal.** A palavra (`FORA`, `RECUPERADO`) é que carrega o estado — no Discord ela vai no título E no `content`, ao lado da cor nativa do embed, porque cor sozinha não informa quem não distingue vermelho de verde. Não "melhore" a mensagem acrescentando 🔴/✅: emoji renderiza diferente em cada cliente, vira quadrado vazio em terminal e em cliente de e-mail antigo, e o leitor de tela anuncia o glifo antes da frase que importa. `event` é `monitor.down` ou `monitor.recovered`. Timeout de 5 s e **sem retry**: um alerta que chega 5 minutos depois não é um alerta, e uma fila de retentativas para um endpoint morto vira acúmulo de memória nesta VM. O incidente fica gravado de qualquer forma — o painel é a fonte da verdade, o alerta é a conveniência. O envio nunca bloqueia a checagem. **Destinos em rede interna são recusados**, no cadastro e no envio. Bloqueados: `localhost`, `127.*`, `0.0.0.0`, `10.*`, `192.168.*`, `169.254.*` (metadata da nuvem), `172.16–31.*`, `::1` e qualquer `*.internal`. É SSRF clássico e o vetor é real: a URL vem do cliente e a requisição sai da nossa VM. `POST /channels/:id/test` manda a mensagem **de verdade** para o destino, na hora, e devolve `{ ok, kind, texto }` — `texto` é o corpo que saiu, para poder ser comparado com o que chegou. Não é uma mensagem simplificada: é o mesmo `payload` e o mesmo formato por kind de um incidente real, porque um teste com corpo próprio provaria só que o corpo próprio funciona. A única diferença é `test: true` no JSON do webhook genérico, para que um teste não entre no sistema de quem recebe como incidente verdadeiro. Se o destino recusar, a resposta é **`502`** (não 500: quem recusou foi o destino do cliente, não este serviço) e traz o mesmo `texto`. É o caminho que pega o modo de falha mais comum destes canais — webhook apagado no Discord, token de bot revogado — que de outra forma só apareceria na próxima queda. `GET /channels` devolve os canais **mascarados** — webhook e Discord viram `origem/…` e o Telegram vira `chat `. O token e a URL completa não voltam nunca: o painel costuma estar aberto durante uma call ou um screenshot. Quem perdeu o valor troca o canal. No Discord isso vale igual ao Telegram: o token no path do webhook É a credencial — quem o tem posta no canal. O canal de `email` é a EXCEÇÃO e volta com os endereços legíveis (os dois primeiros e a contagem do resto: `plantao@acme.com, sre@acme.com +3`). A lista de destinatários não é segredo — é justamente o que se confere para saber se o plantão certo será avisado, e mascará-la obrigaria a recadastrar a lista só para lê-la. ## 6. Páginas de status públicas Uma página de status é uma seleção de monitores com **nome público**, servida como HTML renderizado no servidor. ```bash # 1. cria curl -X POST https://monitors.manager.myinfrastructure.click/status-pages \ -H "x-api-key: mk_…" -H "content-type: application/json" \ -d '{"title":"Acme Status","description":"Estado dos serviços da Acme", "supportUrl":"https://acme.com/suporte","showLatency":true}' # → 201 {"id":"sp_…","slug":"acme-status-a1b2c3", …} # 2. escolhe os componentes (substitui a lista inteira) curl -X PUT https://monitors.manager.myinfrastructure.click/status-pages/sp_…/monitors \ -H "x-api-key: mk_…" -H "content-type: application/json" \ -d '{"monitores":[ {"monitorId":"mon_…","publicName":"API","publicGroup":"APIs"}, {"monitorId":"mon_…","publicName":"Site","publicGroup":"Sites"}]}' # 3. já está no ar curl https://monitors.manager.myinfrastructure.click/s/acme-status-a1b2c3 ``` **`publicName` é obrigatório**, e não tem default vindo do nome interno. Um default faria `payments-api-prod` — que revela stack, ambiente e organograma — vazar por omissão. Até 80 caracteres; `publicGroup` é opcional, até 60. Máximo de 50 monitores por página. O **slug leva sufixo aleatório** (`acme-status-a1b2c3`). "Público por link, sem indexação" só protege enquanto o link não é adivinhável: um slug limpo permitiria enumerar as páginas de todos os clientes com um dicionário curto. O slug é único **globalmente**, não por dono — a rota `/s/:slug` resolve sem credencial. Colisão é `409`. Slugs reservados: `status`, `me`, `monitors`, `incidents`, `channels`, `overview`, `s`, `admin`, `api`, `health`, `www`, `riligar`, `mcp`, `docs`. ### Domínio próprio ```bash curl -X POST https://monitors.manager.myinfrastructure.click/status-pages/sp_…/domain \ -H "x-api-key: mk_…" -H "content-type: application/json" \ -d '{"domain":"status.acme.com"}' ``` ```json {"domain":"status.acme.com","status":"pending", "alvoCNAME":"monitors-manager.fly.dev", "instrucoes":"Crie um CNAME de status.acme.com apontando para monitors-manager.fly.dev. O certificado é emitido depois que o DNS propagar."} ``` **O Hoster não participa disto.** Quando o Hoster resolve um host ele serve arquivos do R2, e não há origem configurável para apontar ao Fly. Aqui o próprio manager pede o certificado à Fly, e o cliente aponta um `CNAME` para `monitors-manager.fly.dev`. `GET .../domain` consulta a Fly de verdade e só devolve `active` quando o certificado está **emitido** — nunca no momento em que registramos. É a armadilha que o Hoster documenta: um hostname marcado "ativo" na criação faz o painel mentir sobre domínios que não resolvem. Domínios da própria stack (`*.fly.dev`, `*.myinfrastructure.click`, `*.internal`), `localhost`, IP literal e endereço malformado são `400`. Sem essa guarda alguém reivindicaria `monitors-manager.fly.dev` e a resolução por Host passaria a servir a página dele na raiz do serviço. Sem `FLY_API_TOKEN` no ambiente, `POST .../domain` responde **503** — falha FECHADA, ao contrário do gate de plano. Confirmar "domínio configurado" para entregar uma página que não abre por HTTPS é o tipo de mentira que este produto existe para não contar. ### O que a página mostra — e o que nunca mostra `GET /s/:slug/data.json` (e `/data.json` no domínio do cliente) devolve exatamente o que o HTML renderiza: ```json {"titulo":"Acme Status","descricao":"…","suporte":"https://…", "geral":"operational", "componentes":[{"nome":"API","grupo":"APIs","estado":"up","uptime":99.94, "dias":[{"dia":"2026-06-08","uptime":100}, …], "latencia":[{"dia":"2026-06-08","p50":120,"p95":310}, …]}], "incidentes":[{"componente":"API","inicio":1757…,"fim":1757…, "duracaoMs":420000,"aberto":false,"causa":"Falha de conexão"}], "atualizadoEm":1757…} ``` `geral` é `operational` · `degraded` · `down` · `unknown`. **Fora, deliberadamente**: a URL do alvo, o nome interno do monitor, os ids, o `config` (que carrega headers de autenticação), o status HTTP, a mensagem de erro crua, o intervalo de checagem (revela o plano), a contagem de checagens (volume operacional) e os scores de Lighthouse. A causa de um incidente passa por um vocabulário **fechado** — "Tempo de resposta excedido", "Resposta inesperada do serviço", "Falha de conexão", "Degradação de desempenho", e "Indisponibilidade" para o que não casa. A mensagem original costuma trazer a URL interna, e limpá-la por regex falharia no primeiro formato novo de erro. `uptime` vem `null` quando nada foi medido no dia — **nunca 100**. ### Aparência Tudo pelo `PATCH /status-pages/:id`: | Campo | Aceita | | --- | --- | | `brandColor` | hex `#rgb` ou `#rrggbb`; `""` ou `null` remove | | `fontFamily` | `marca` (Montserrat, padrão) · `humanista` · `serifada` · `mono` | | `theme` | `auto` · `light` · `dark` | | `footerText` | até 200 caracteres | | `refreshS` | 15 a 3600 segundos (padrão 60) | | `customCss` | até 20 000 caracteres, **podado** | | `isPublic`, `showLatency`, `slug`, `title`, `description`, `supportUrl` | — | A cor é aceita **só como hex** porque ela entra num `