Pular para conteúdo

Contrato da API REST

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15

Contrato narrado dos endpoints. A referência interativa (OpenAPI/Swagger) é gerada do código; o Javadoc interno fica em dev/api/. Todos os endpoints expõem CORS; os POST públicos têm preflight OPTIONS → 204.

Formato de erro (problem+json)

Todo corpo de erro é RFC 7807 — application/problem+json (decisão 0016):

{ "type": "about:blank", "title": "Unauthorized", "status": 401,
  "detail": "Token inválido ou expirado", "code": "invalid_token" }

O code é o machine-code estável (o antigo error) — use-o para lógica de cliente (missing_token, invalid_token, cliente_not_found, user_exists, …); o detail é legível e pode mudar. As tabelas por endpoint abaixo listam o par status/code.

Duas exceções que não são problem+json (por design): o relatório 502 do POST /api/setup/provision (corpo de sucesso, ver abaixo) e o 403 {"status":"PENDENTE"|"SPAM"} do client-login (sinal de fluxo, não erro).

Autenticação (emissão de tokens)

GET /.well-known/jwks.json

Chave pública RSA para o PowerSync validar os JWT. Sempre com alg=RS256 e o kid de AUTH_KEY_ID. (Alias interno: GET /api/auth/jwks.)

POST /api/auth/pabast/login

Login do usuário do ERP. Sem autenticação. app_id é opcional (016): com ele o token vira de app-user — carrega oauth_app_id/oauth_cliente_id + role (papel do usuário no app, app_user_access(pabast)). Fail-closed: sem papel (ou app não catalogado) o token sai sem claim role, mas login 200 (credencial ERP é válida — nunca 403 por falta de papel). Sem app_id → token legado (scope=full, sem role/app) — consumidores atuais inalterados.

// Request  (app_id opcional)
{ "cliente": "vantroba", "id_usuario": "123", "senha": "1234", "senha_compl": "567",
  "app_id": "bi-comercial" }

// 200
{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 86400,
  "scope": "full", "id_usuario": "123", "nome": "FULANO" }
Status Significado
200 JWT scope=full
400 campos ausentes/inválidos ou cliente não encontrado
401 credenciais incorretas
403 cliente inativo
429 rate limit (60 req/min por IP) — Retry-After: 60

POST /api/auth/firebase/login

Login da equipe xadm. Header Authorization: Bearer <firebase_id_token>. Emite JWT scope=admin com xadm_admin=true apenas para @xadm.com.br.

Status Significado
200 JWT admin
400 token ausente ou > 8 KB
401 token Firebase inválido/expirado
403 e-mail fora do domínio xadm
503 Firebase não configurado (AUTH_FIREBASE_PROJECT_ID ausente)

POST /api/auth/firebase/client-login

Login de usuário de app terceiro. Header Authorization: Bearer <firebase_id_token> + corpo:

{ "cliente": "vantroba", "app_id": "meu-app" }

O cliente precisa estar ativo e ter oauth_firebase em auth_methods. Modelo de dois eixos (V11): status (lifecycle) + role (papel do catálogo do app, claim no JWT). Só ATIVO emite token; o role vai verbatim no claim (consumido pela sync rule do PowerSync).

Status do pedido Resposta
ATIVO 200 JWT scope=full com claim role (verbatim do catálogo)
REJEITADO 403 {"status":"REJEITADO","message":…,"admins":[…]}
INATIVO 403 {"status":"INATIVO","message":…,"admins":[…]}
PENDENTE / inexistente 403 {"status":"PENDENTE","message":…,"admins":[…]} (cria/atualiza o pedido)
conta @xadm.com.br 200 JWT role=ADMIN (super-admin implícito; não grava pedido)

Todo 403 carrega admins: [{nome, email}] — os ADMINs ativos daquele (cliente, app), para a tela "Acesso Pendente" dizer a quem pedir liberação. É deliberado que venha no corpo do erro: o usuário ainda não tem JWT, então não pode chamar o GET /api/apps/admins-contato, que serve a mesma lista para quem já entrou. email é null quando o ADMIN é pAbast (o ERP não tem e-mail) — a tela mostra só o nome, sem mailto. Lista vazia ([]) quando não há ADMIN: o app cai no "contate o suporte xadm". Corpo em JSON simples, não problem+json, porque é resposta de fluxo lida pelo browser (mesma razão do @Error local com CORS, decisão 0016).

Trade-off aceito: o roster sai em todos os 403

Qualquer portador de um ID Token válido do projeto Firebase que saiba cliente + app_id recebe os nomes e e-mails dos ADMINs daquele par — inclusive quem está REJEITADO. É mais exposição que o admins-contato, que exige um JWT de acesso ATIVO. Foi decidido manter (2026-09-04): cobre também quem quer contestar uma rejeição, e o custo é uma lista de contatos internos do cliente, não credencial. Se isso virar vetor de coleta de e-mail, o corte natural é devolver admins só em PENDENTE.

Ao criar um PENDENTE novo, o backend dispara uma notificação (Resend, fail-open — nunca falha o login) aos aprovadores: env AUTH_OAUTH_NOTIFY_EMAILS ∪ ADMINs ativos do (cliente, app).

POST /api/auth/refresh

Troca um JWT de app-user ainda válido, emitido pelo central, por um novo com o status e o papel atuais — a mesma decisão do login, sem re-provar a credencial (decisão 0034). Header Authorization: Bearer <JWT vigente>, sem corpo: cliente e app saem do próprio token (oauth_cliente_id, oauth_app_id). Serve ao app que precisa enxergar troca de papel ou inativação sem logout; o bi-comercial chama no boot, no foco da aba e a cada 5 min.

O 200 é o mesmo corpo do login (access_token, token_type, expires_in…). A decisão espelha o login: pAbast sem acesso ATIVO sai 200 sem role (o app mostra a tela peça-ao-admin, e reativar reflete sem logout); oauth sem acesso ATIVO é 403 access_revoked; a conta @xadm.com.br segue ADMIN implícito.

O token novo copia o auth_time, o último login com credencial. Passado o teto AUTH_SESSION_MAX_HOURS (default e máximo 168 h) desde ele, o refresh recusa, e o exp emitido nunca passa do teto: perto do fim da sessão o expires_in vem menor que o TTL.

Status code Quando O app faz
200 — JWT novo com status e papel atuais troca o token
401 missing_token / invalid_token sem Bearer, assinatura inválida ou token expirado desloga
403 not_app_user token sem oauth_app_id (legado scope=full, anônimo, xadm_admin) mantém (não há via de refresh)
403 access_revoked pAbast fora do pabast_senhas, ou oauth sem acesso ATIVO desloga
403 session_expired passou do teto de sessão desloga
403 cliente_inactive cliente desativado ou apagado desloga
403 auth_method_not_allowed método (pabast/oauth_firebase) tirado do cliente desloga
429 rate_limit_exceeded rate limit (60 req/min por IP, o mesmo dos logins) — Retry-After: 60 tenta de novo

Os code do 403 são contrato

O app decide deslogar pelo code (_codigosRevogacao no AppConnector do bi-comercial). Código novo de revogação entra lá também.

O refresh não revoga o token anterior: o PowerSync e toda API aceitam o velho até o exp. Por isso o prazo de revogação é o TTL do access token, que a norma da casa limita a 60 min (ADR central 0037); o refresh é o que deixa o TTL curto sem obrigar o usuário a re-logar.

POST /api/auth/anon?cliente=X — depreciado

Emite JWT scope=pabast_only (exp 1 h). Mantido só para compatibilidade com o bi-transporte (decisão 0006). 400 sem cliente/cliente inexistente; 403 cliente inativo; 429 rate limit.

Administração (/api/admin/**)

Protegido pelo AdminAuthFilter: header Authorization: Bearer <jwt> com xadm_admin=true. Sem isso → 401 (ausente/inválido) ou 403.

Método / rota Ação
GET/POST /api/admin/clients listar / criar cliente
PUT/DELETE /api/admin/clients/{clienteId} atualizar / remover cliente
GET/POST /api/admin/pabast/users listar (?cliente=; ?app= inclui app_role/app_status) / criar usuário pAbast
PUT/DELETE /api/admin/pabast/users/{clienteId}/{id} atualizar / remover (par (cliente_id, id))
POST /api/admin/pabast/users/{clienteId}/{id}/role xadm: atribuir papel pАbast ({app_id, role}; bootstrap do 1º admin)
POST /api/admin/pabast/users/{clienteId}/{id}/access-status xadm: ativar/inativar acesso ({app_id, status∈{ATIVO,INATIVO}}; sem 409 lockout)
GET /api/admin/oauth/firebase/requests fila de pedidos OAuth (?status=/?cliente=)
POST /api/admin/oauth/firebase/requests/{id}/approve aprovar → ATIVO ({"role":"<catálogo>"}; 422 role_not_in_catalog)
POST /api/admin/oauth/firebase/requests/{id}/reject rejeitar (→ REJEITADO)
POST /api/admin/oauth/firebase/requests/{id}/status alterar status (PENDENTE\|REJEITADO\|ATIVO\|INATIVO; ativar exige role → 422 role_required)
GET /api/admin/apps/{app}/roles catálogo de papéis do app
POST /api/admin/apps/{app}/roles criar papel ({"role","label"}; 404 se app não catalogado; 409 role_exists)
DELETE /api/admin/apps/{app}/roles/{role} remover papel
GET /api/admin/integradores · DELETE /api/admin/integradores/{clienteId} instalações do integrador-client / parar de acompanhar — ver Heartbeat do integrador-client
POST /api/admin/apps/{app}/roles/sync ingere o catálogo que o app declara em GET <base>/roles-catalog.json — upsert, nunca deleta. Corpo opcional {"base_url":"…"} sobrepõe a production_url. Devolve {app_id, base_url, declarados, criados, atualizados, roles[]}. 404 app não catalogado · 422 app_sem_url/base_url_invalida/role_muito_longo/label_muito_longo · 502 roles_catalog_unreachable (app fora do ar ou JSON inválido). Ver decisão 0025

As respostas administrativas nunca incluem hashes de senha.

Auto-governança do tenant (/api/apps/**)

Superfície self-service para o role=ADMIN de um app gerir os próprios usuários. Autenticada pelo AppUserAuthFilter (JWT de app com oauth_app_id; tokens sem ele — pAbast/anônimo/xadm — → 403); o gate role=ADMIN é por-endpoint. Escopo sempre pelo claim (oauth_cliente_id, oauth_app_id) — nunca pelo body/path. Um id fora do escopo → 404.

Método / rota Ação
GET /api/apps/roles catálogo de papéis do meu app (picker)
GET /api/apps/usuarios usuários do meu app — source:pabast (com id/ChaveUsu, dedup) ∪ source:oauth
POST /api/apps/usuarios/oauth/{id}/approve aprovar oauth → ATIVO ({"role":"<catálogo>"})
POST /api/apps/usuarios/oauth/{id}/status status oauth; guard 409 last_admin (não zera o último ADMIN ativo)
POST /api/apps/usuarios/pabast/{id}/role atribuir papel a usuário pАbast ({id}=ChaveUsu; 422/409/404)
POST /api/apps/usuarios/pabast/{id}/status status de usuário pАbast — INATIVO/REJEITADO/PENDENTE valem sem papel (barra quem nunca vai usar o app, criando a linha se não existir); ATIVO exige papel já concedido (422 role_required); guard 409 last_admin; 404 fora do escopo
GET /api/apps/admins-contato contato dos ADMINs ([{nome,email}], email null p/ pАbast) — acessível a qualquer app-user, mesmo SEM papel (tela "peça ao admin"); 200 [] se não há admin

Escalonamento total: o ADMIN concede qualquer papel do catálogo do seu app (inclusive ADMIN/ DIRETORIA), restrito ao seu (cliente, app). xadm é meta-admin acima (via /api/admin/**).

Estrutura do JWT emitido

{ "sub": "vantroba/123", "iss": "https://auth.xadm.biz", "aud": "powersync-vantroba",
  "scope": "full", "iat": 1704067200, "exp": 1704153600 }

scope: full (login/aprovado), admin (xadm), pabast_only (anônimo, depreciado). O aud vem de clientes.audience e deve casar com o PowerSync.

No login social (client-login) o token carrega ainda oauth_cliente_id, oauth_app_id e role (verbatim do catálogo app_roles) — é o role que a sync rule do PowerSync consome para filtrar o bucket por papel (a segurança de dado mora aqui, não na UI).

Todo token de app-user (pAbast com app_id, client-login e refresh) carrega também sub_type (pabast|oauth, quem é o sub) e auth_time (epoch s do último login com credencial, que o refresh copia). O token pAbast nunca carrega email; o oauth sempre carrega.

Central de Apps (build-time)

Ver a decisão 0010 e o modelo de dados. Três prefixos disjuntos, três guards.

Broker de provisão (/api/setup/** — token do dia)

POST /api/setup/provision — disparado pelo /xadm-setup. Corpo {app_id, feature, ...meta} (feature ∈ glitchtip|garage|analytics|docs). O ...meta vem do app.json e nome + grupo são obrigatórios (colunas apps.nome/apps.grupo são NOT NULL; grupo ∈ xadm|cliente) — o broker faz upsert do catálogo a cada chamada. Faz upsert do app no catálogo, provisiona idempotente e devolve o client-grade (o setup grava no app.json). Além disso, para app servidor (com production_url), injeta o client-grade como env <PROVIDER>_<KEY> no recurso Coolify (ver runbook).

Resposta honesta (0.17.6 — decisão 0011). A resposta carrega, além do client_grade, um array providers[] com o estado real por provider — state (ligada|falha), last_error, e o resultado da injeção no Coolify (injected true|false|null + injected_reason). O client_grade só traz refs de providers ligada (nunca enabled sem ref).

{ "app_id": "auth", "feature": "glitchtip",
  "client_grade": { "enabled": true, "dsn": "https://…@bug.xadm.biz/8" },
  "providers": [ { "provider": "glitchtip", "state": "ligada",
                   "last_error": null, "injected": true, "injected_reason": null } ] }

O broker nomeia o projeto GlitchTip pelo app_id (0.17.5 — slug estável, sem duplicata).

analytics provisiona dois providers — Aptabase (eventos, 0013) e Metabase (BI, 0014); o providers[] traz os dois sub-estados e o client_grade ganha aptabase_key + aptabase_host (o SDK Flutter exige InitOptions(host:)) + metabase_dashboard_id (link do dashboard). O corpo aceita eventos (lista opcional dos eventos do app) — usada pra decidir cards do dashboard (ex.: "Telas Mais Usadas" só se screen_view está na lista).

Erros (problem+json, 0016). 400 app_id_obrigatorio (app_id ausente/vazio) · 400 feature_obrigatoria (feature ausente/vazia) · 400 feature_desconhecida (feature fora de glitchtip|garage|analytics|docs) · 400 nome_obrigatorio (nome ausente/vazio) · 400 grupo_invalido (grupo fora de xadm|cliente) · 400 cliente_desconhecido — o cliente_id do app não existe em clientes. O cliente_id é a chave minúscula do tenant (ex.: vantroba), não o nome de exibição (Vantroba). Todos os campos obrigatórios são validados no service (não no bean-validation da borda), então cada um carrega um machine-code estável consistente — e barram antes do upsert (guardam as colunas restritivas de apps — nome/grupo NOT NULL, FK apps_cliente_id_fkey — que sem elas vazariam um 500 opaco no PERSIST). Apps grupo=xadm mandam cliente_id nulo.

Auth = token de serviço no formato X-ADM-<código> (endpoint máquina-a-máquina, não login): Authorization: Bearer X-ADM-<código do dia>, aceito hoje ± 1 dia. A regra que gera o <código> vive só no código do auth e é sabida pelos admins xadm (fora de banda) — não é publicada (nem na skill, nem aqui). É segurança fraca por decisão — pressupõe o /api/setup/** fora da rota pública. O /api/admin/** (dashboard humano) segue com JWT xadm_admin.

Status Significado
200 ≥1 provider ligou — {app_id, feature, client_grade, providers[]}
400 feature fora do vocabulário fechado
401/403 sem token Bearer / token do dia inválido
502 nenhum provider ligou (o corpo traz providers[].last_error)

Dashboard (/api/admin/apps — JWT xadm_admin)

Método / rota Resultado
GET /api/admin/apps lista o catálogo: por app, estado de cada provider + client_config + links — app-level (producao, docs derivado do slug, source=repo_url) e contextuais resolvidos server-side só quando o provider está ligada: glitchtip (<base>/<org>/<slug>), metabase (<base>/dashboard/<id>) e metabase_embed (URL de embed assinada — null sem METABASE_EMBEDDING_SECRET). Ver decisão 0017
GET /api/admin/apps/{appId} detalhe de um app (404 se não catalogado)
GET /api/admin/apps/{appId}/acessos acessos de terceiros por (cliente, app) — cada item traz id (do pedido), usado para aprovar/recusar

As respostas nunca incluem secret_ref/segredos. Aprovar/recusar acesso de terceiro reusa /api/admin/oauth/firebase/requests/**. Os corretores runtime (/api/apps/**) e a injeção de secret no Coolify (/api/setup/ensure-secrets) entram nas fases de provider (ainda não implementadas).

Monitor de infra (/api/admin/monitor — JWT xadm_admin)

Saúde de infra do Coolify (Fase 1, read-only, sob demanda — decisão 0019). Cada endpoint responde 200 com degradação parcial por fonte: a fonte que falha vira fontes: {..:"erro"} + erros: {<fonte>:{code,msg}} e seu bloco de dado vem null (o endpoint não falha; ApiException/problem+json fica só para erro real, ex. 403). codes estáveis: coolify_api_off, docker_proxy_off, host_proc_indisponivel, disco_indisponivel, docker_df_indisponivel.

Método / rota Resultado
GET /api/admin/monitor/host {fontes, erros, mem{total,usada,livre}, swap{total,usada}, disco{total,usado,pct}, build_cache{reclaimable,total}} — bytes; pct 0–100. Fontes: proc, disco, docker_df
GET /api/admin/monitor/apps {fontes, erros, apps:[…]}. Por app: uuid, nome, fqdn (pode ser null), estado+health (do status do Coolify), limits_memory/limits_memory_reservation/limits_cpus (string com unidade; "0"=sem limite, destacável), ram_viva/ram_pct_limite/cpu_pct (vivos; null sem docker/container). Fontes: coolify, docker

App compose (stacks) mapeia N containers → ram_viva/cpu_pct somados. Join por coolify.name ⊇ uuid. Contêiner lento degrada por-item (aquele app com vivos null), sem derrubar o endpoint. Provisionamento (proxy + mounts + env) no runbook.

Frota de deploy (/api/admin/deploy — JWT xadm_admin)

Painel de deploy do central-ui (Fase 3 do control-plane — decisão 0021), read-only. Junta o mapa app_deploy_targets + o último deploy_audit por recurso + o estado vivo do Coolify (reusa o MonitoramentoService, indexado por uuid). Mesma degradação parcial do monitor (fontes/erros com code/msg; Coolify fora → recursos aparecem com vivos null, 200).

Método / rota Resultado
GET /api/admin/deploy {fontes, erros, apps:[…]} agrupado por app (registry_slug). Por app: catalogo ({app_id, nome, grupo, cliente_nome} — enriquecimento best-effort por host do fqdn==host do production_url; null quando não casa) + recursos:[{target, instance, coolify_uuid, fqdn, estado, health, ram_viva, ram_pct_limite, cpu_pct, ultimo_deploy{resultado, deployment_uuid, criado_em}}]. ultimo_deploy null = nunca deployado

Identidade ancorada no slug de registry da imagem (não app_id — imune a rebrand). Distinta da superfície M2M /api/ci/deploy (token dedicado, dispara): esta só lê.

CI M2M (/api/ci/** — token CENTRAL_DEPLOY_TOKEN)

Fronteira do CI, sob o DeployAuthFilter (@ServerFilter("/api/ci/**"), Bearer CENTRAL_DEPLOY_TOKEN, fail-closed). Em prod, token ausente ou vazio recusa o boot (DeployTokenGuard) em vez de subir com a rota em 401 a tudo; fora de prod (e2e) vale só o fail-closed. Disparada pelos jobs do pipeline.yml dos apps.

Método / rota Resultado
POST /api/ci/deploy {image, target?, app_id?, instance?} Âncora por imagem (apps de registry — jar\|native\|web): resolve os recursos Coolify pela imagem e dispara o deploy (async); target ausente cai no prefixo da tag, app_id é só rótulo de auditoria.
POST /api/ci/deploy {app_id, target, instance?} Âncora por app_id (app compose/build-from-git, sem imagem de registry — ex. stacks PowerSync): app_id é o slug do git repo que o reconcile ancorou, target é obrigatório (compose). Ver 0026.
— respostas dos dois 200 ok/parcial · 502 nada-enfileirado · 404 no_deploy_target (nem após reconcile on-miss) · 400 invalid_image / invalid_target (fora de {jar, native, web, compose}) / missing_anchor (nem image nem app_id) / missing_target (sem image e sem target). Ver 0021/0022.
GET /api/ci/deploy/{deployment_uuid} Status do deployment (via Coolify, degradável).
POST /api/ci/docs-published {slug, alvo} O job docs avisa que publicou → o central cutuca o listener interno do container docs (/cgi-bin/_sync, ADR central 0028). 200 {status:"poked", slug} · 502 docs_sync_falhou (poke não passou; o CI engole, backstop de 60min cobre) · 503 docs_sync_indisponivel (DOCS_SYNC_URL/DOCS_API_TOKEN ausente) · 400 slug_invalido ([a-z0-9-]+). alvo é só logado (o nginx sincroniza o prefixo inteiro do slug).

Heartbeat do integrador-client

Cada instalação do integrador-client (jar on-premise, uma por cliente) manda um heartbeat ao integrador-server do próprio cliente, que carimba o cliente_id e o repassa aqui. O central guarda o estado atual, alerta no GlitchTip depois de 6 horas úteis de silêncio e lista as instalações para a aba Deploy do central-ui (decisão 0028, runbook heartbeat do integrador-client). O salto client → integrador-server é contrato do integrador-server: heartbeat.md.

Serialização (defaults do micronaut-serde): campo desconhecido na entrada é ignorado; datas saem como string ISO-8601; nomes em snake_case. A saída leva @JsonInclude(ALWAYS), então null e [] vão no fio — mas o consumidor trata chave ausente como vazia/nula mesmo assim.

POST /api/integrador/heartbeat — M2M, token CENTRAL_API_TOKEN

Chamado pelo integrador-server. Header Authorization: Bearer <CENTRAL_API_TOKEN> (filtro próprio IntegradorAuthFilter, comparação em tempo constante; o token só abre /api/integrador/**). Token vazio fecha a rota (401 a tudo); em prod, token vazio recusa o boot (IntegradorTokenGuard).

{"cliente_id":"maxsul","versao":"0.2.0","fluxos":["pied"],"hostname":"SRV-ERP01","motivo":"inicio","iniciado_em":"2026-09-10T08:00:03-03:00"}
Campo Obrigatório Regra validada pelo central
cliente_id sim ^[a-z0-9-]{1,50}$; tem de existir em clientes.cliente_id (senão 422)
versao sim não vazio, ≤ 40
fluxos sim (não nulo) ≤ 20 itens, cada um ^[a-z0-9-]{1,50}$
hostname não ≤ 255
motivo não inicio | periodico
iniciado_em não ISO-8601 com offset (lido como OffsetDateTime)
Status code Quando
204 — gravou
400 BAD_REQUEST com invalid-params quando viola Bean Validation (tabela acima); sem invalid-params quando o corpo não desserializa (JSON inválido, iniciado_em malformado)
401 missing_token | invalid_token Bearer ausente ou errado
422 cliente_desconhecido cliente_id não existe em clientes

O 422 sai também em WARN no log do central (é misconfiguração do CLIENTE_ID do integrador-server, e o processor da casa loga 4xx só em DEBUG). Efeito: upsert de uma linha por cliente em integrador_instancias. O 1º heartbeat registra a instalação; os seguintes atualizam tudo menos primeiro_heartbeat_em. ultimo_heartbeat_em é o relógio do central, nunca o do client. Heartbeat numa instalação em silêncio encerra o episódio (alertado_em volta a null). Repetir o mesmo heartbeat só move ultimo_heartbeat_em.

GET /api/admin/integradores — JWT xadm_admin

200, array ordenado por cliente_id; nenhuma instalação → []. Sem paginação (no máximo uma linha por cliente).

[{"cliente_id":"maxsul","versao":"0.2.0","fluxos":["pied"],"hostname":"SRV-ERP01","motivo":"periodico","iniciado_em":"2026-09-10T11:00:03Z","primeiro_heartbeat_em":"2026-09-10T11:00:05.412Z","ultimo_heartbeat_em":"2026-09-10T15:00:07.118Z","horas_uteis_sem_heartbeat":0.4,"situacao":"ativo"}]
Campo Tipo no fio Regra
cliente_id string sempre
versao string sempre
fluxos array de string sempre, mesmo []
hostname string ou null chave sempre presente
motivo string ou null chave sempre presente
iniciado_em string ISO UTC (Z) ou null o offset original do client não é preservado (timestamptz guarda o instante)
primeiro_heartbeat_em string ISO UTC (Z) sempre; pode ter fração de segundo
ultimo_heartbeat_em string ISO UTC (Z) sempre; pode ter fração de segundo
horas_uteis_sem_heartbeat número 1 casa decimal
situacao string ativo | silencioso | cliente_inativo

situacao, nesta precedência: cliente_inativo se o cliente está desativado (clientes.active = false); senão silencioso se há episódio de silêncio aberto; senão ativo. Horas úteis = tempo dentro de 07:00–22:00, seg–sex, sem feriado nacional, em America/Sao_Paulo, desde o último heartbeat.

Erros: 401 missing_token | invalid_token; 403 insufficient_permissions (JWT sem xadm_admin).

DELETE /api/admin/integradores/{clienteId} — JWT xadm_admin (idempotente)

Status code Quando
204 — apagou, ou a instalação já não existia
401 missing_token | invalid_token Bearer ausente ou errado
403 insufficient_permissions JWT sem xadm_admin

Idempotente de propósito: um 404 numa requisição com credencial vira ERROR no GlitchTip (regra da xadm-comum-web 0.7.1+). Apagar não desliga: se o jar ainda roda, o próximo heartbeat recria a linha. Consumidor: o botão "Parar de acompanhar" da aba Deploy do central-ui.

Corretores runtime (/api/apps/** — JWT de usuário)

Superfície runtime dos apps, sob o AppUserAuthFilter (JWT de usuário emitido pelo auth, com o claim oauth_app_id). Autz (I2): o app_id pedido tem que casar com o oauth_app_id do token → 403 cross-app. Token sem oauth_app_id → 403; sem Bearer → 401.

GET /api/apps/garage/presign — corretor de arquivos (Garage)

Devolve uma URL presignada curta (SigV4, 10 min) para um objeto do bucket do app — a write-key nunca sai do servidor (broker-signed, decisão 0012).

Query Valor
app_id o app (deve casar com o oauth_app_id do token)
op GET (download) ou PUT (upload)
key a chave do objeto no bucket

// 200
{ "url": "https://arquivo-api.xadm.biz/<bucket>/<key>?X-Amz-Signature=…", "expires_in": 600 }
403 cross-app · 404 app sem Garage provisionado · 400 op inválido.

Smoke-tests (/test)

Superfície de testes simples e inócuos rodáveis em produção, sob o RateLimiter (30/min por IP). Read-only, sem segredo, sem mutação de estado (0011). A página é pública; o disparo do GlitchTip exige credencial admin, como pede a norma da casa: o AdminAuthFilter cobre /test/glitchtip por path, com o mesmo Bearer de /api/admin/** (JWT xadm_admin ou o shared secret do integrador) e os mesmos erros problem+json.

Método / rota Auth Resultado
GET /test nenhuma página HTML com a lista de testes e como disparar cada um
POST /test/glitchtip Bearer xadm_admin dispara uma exceção benigna reportada ao GlitchTip (valida o pipe de erro) e responde 200 HTML com o id do evento; sem SENTRY_DSN → "não configurado" (no-op)
curl -X POST -H "Authorization: Bearer $JWT_XADM_ADMIN" https://central-backend.xadm.biz/test/glitchtip
Status code
401 missing_token (sem Bearer) · invalid_token
403 insufficient_permissions (JWT sem xadm_admin)
429 rate limit (corpo HTML, não problem+json)