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) |