Table of Contents
Central de Aplicações e Autenticação (backend)¶
A Central de Aplicações e Autenticação (backend, auth.xadm.biz) é a porta de entrada de autenticação do ecossistema xadm.biz.
Os apps Flutter e as telas web pedem a ele um crachá digital (um token JWT) ao
fazer login; com esse crachá, o usuário acessa seus dados sincronizados sem que
cada sistema precise saber como validar uma senha. Antes, essa responsabilidade
vivia espalhada dentro de cada instância do integrador — agora há um único
serviço central, com um único endereço para validar tokens.
Na prática: o usuário entra com suas credenciais do pAbast, o serviço confere a identidade e devolve um token assinado. Esse token diz quem é o usuário e o que ele pode ver — inclusive um modo especial que libera só a tabela de senhas, para o app conseguir funcionar offline na primeira vez. O serviço também publica a chave pública que o PowerSync usa para conferir que o token é legítimo.
O serviço também acompanha as instalações do integrador-client que rodam dentro de cada cliente: cada uma manda um sinal de vida periódico com a versão e os fluxos ligados, e, se uma ficar calada por mais de 6 horas úteis, a equipe recebe um alerta — em vez de descobrir dias depois que a integração parou.
→ Documentação Completa — o sistema inteiro, de cima a baixo.
Atalhos¶
- Documentação Completa — contexto, modelo de dados, fluxos e contratos.
- Decisões — o porquê de cada escolha técnica (ADRs).
- Operação — runbooks: gerar chaves, deploy no Coolify, configurar PowerSync, rotacionar chaves.
- Dev / API — contrato da API REST e guia do código.
- Glossário do projeto — vocabulário do domínio (JWT, JWKS, pAbast, bootstrap offline…).
Projeto
Documentação Completa — Central de Aplicações e Autenticação (backend)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
O sistema como ele é hoje, em ordem de raciocínio: o problema, o que entra, como os dados são guardados, como a autenticação funciona e, no fim, os contratos que outros sistemas consomem. Quem só integra pode pular direto para o cap. 7. O histórico ("como chegamos aqui") está no apêndice; o porquê de cada escolha, nas Decisões.
1. Contexto e problema¶
Os apps Flutter e as telas web do ecossistema xadm.biz precisam saber quem é o usuário e o que ele pode ver antes de sincronizar dados. Cada app não deve reimplementar validação de senha nem guardar chaves — essa responsabilidade vivia embutida em cada instância do integrador, com um par de chaves RSA e um JWKS por instância, exigindo coordenação a cada cliente novo.
A Central de Aplicações e Autenticação centraliza isso num único serviço (auth.xadm.biz,
Java/Micronaut): valida credenciais, emite um JWT RS256 assinado e publica a
chave pública (JWKS) que o PowerSync usa para conferir os tokens. Um único
endpoint de validação por ambiente, sem coordenar chaves entre instâncias.
São três formas de autenticar, todas terminando num JWT assinado pela mesma chave:
- pAbast — usuário do ERP, com senha de 4 dígitos + complementar de 3
(
scope=full); - Firebase (equipe xadm) — login Google
@xadm.com.br→ JWT comxadm_admin=true(scope=admin), que abre a área administrativa; - Firebase (usuário de app terceiro) — login Google de quem não é xadm,
sujeito a aprovação da equipe (
scope=fullcom papelUSER/ADMIN).
Fora do escopo: validar JWT (o serviço emite; o PowerSync valida), gerir cadastro de usuários do ERP (a fonte é o X-Adm) e sincronizar dados de BI (responsabilidade do PowerSync + integrador).
2. Dados de entrada¶
O serviço não ingere arquivos — recebe requisições HTTP/JSON e lê o banco
db_auth. As entradas que importam:
- Login pAbast (
POST /api/auth/pabast/login):cliente,id_usuario,senha(4 dígitos),senha_compl(3 dígitos). A senha é conferida contra o hash empabast_senhaspelo algoritmo pAbast — o serviço nunca vê senha de usuário em texto claro persistido. - Login Firebase (
/api/auth/firebase/logine/client-login): um Firebase ID Token no headerAuthorization: Bearer(rejeitado se > 8 KB), validado pelo JWKS público do Google. Oclient-loginexige aindaclienteeapp_idno corpo. - Refresh (
POST /api/auth/refresh): só o JWT de app-user vigente noAuthorization: Bearer, sem corpo — cliente e app saem do próprio token. - Token anônimo (
POST /api/auth/anon?cliente=X): só ocliente. Depreciado (ver cap. 5). - Admin (
/api/admin/**): um JWT próprio comxadm_admin=trueno header, validado peloAdminAuthFilter. - Heartbeat do integrador-client (
POST /api/integrador/heartbeat): o sinal de vida de cada instalação on-premise do integrador-client —cliente_id, versão, fluxos, hostname —, repassado pelo integrador-server do cliente com o BearerCENTRAL_API_TOKEN(cap. 5).
3. Modelo de dados¶
O schema abaixo é a fonte da verdade — toda migration que evolui o banco atualiza este modelo no mesmo PR.
O schema vive no PostgreSQL db_auth — banco de propriedade exclusiva do
central-backend —, versionado por Flyway (V1..V16; migration aplicada é congelada,
nunca editada — sempre uma nova). O PostgreSQL é 18+: as PKs surrogate usam
uuidv7() (time-sortable) nativo.
Tabelas de acesso/identidade: o catálogo de clientes, os hashes de senha do pAbast em
pabast_senhas, o acesso unificado do usuário ao app app_user_access (pАbast ∪ oauth,
carrega status + role) e o catálogo de papéis por app app_roles. (A oauth_access_grants
foi dropada na V11; a oauth_access_requests virou app_user_access na V13 — ver abaixo.) O
catálogo de apps (apps, app_provider_resources) é descrito na seção da Central de Apps, e as
instalações do integrador-client (integrador_instancias), na seção do heartbeat.
Relacionamentos (overview)¶
erDiagram
clientes ||--o{ app_user_access : "cliente_id (FK)"
clientes ||--o| integrador_instancias : "cliente_id (FK)"
apps ||--o{ app_roles : "app_id (FK)"
clientes {
uuid id PK
varchar cliente_id UK
}
pabast_senhas {
varchar id "ChaveUsu ERP — sem PK"
varchar cliente_id
}
app_user_access {
uuid id PK
varchar subject_type "oauth|pabast"
varchar subject_key "firebase_uid | id ChaveUsu"
varchar status "PENDENTE|REJEITADO|ATIVO|INATIVO"
varchar role "catálogo por app (nullable)"
}
app_roles {
varchar app_id FK
varchar role
}
integrador_instancias {
varchar cliente_id PK
timestamptz ultimo_heartbeat_em
timestamptz alertado_em "não nulo = silêncio aberto"
}
pabast_senhasnão tem FK paraclientes. A tabela foi recriada no layout do ERP (V3) como tabela independente; o vínculo com o cliente é lógico (colunacliente_id), e a unicidade do par(cliente_id, id)é mantida em software (decisão 0008), não por constraint.
Dicionário por tabela¶
clientes — catálogo de clientes do central-backend¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V1 | uuidv7() |
cliente_id |
varchar(50) | UK | NOT NULL | V1 | ID de negócio (vantroba, onpetro); vai no sub/URLs |
audience |
varchar(100) | NOT NULL | V1 | aud do JWT; deve casar com o client_auth.audience do PowerSync |
|
active |
boolean | NOT NULL | V1 | false → login e token anônimo retornam 403 |
|
created_at |
timestamptz | NOT NULL | V1 | NOW() |
|
auth_methods |
text[] | NOT NULL | V2 | métodos habilitados; default {pabast}; oauth_firebase libera o client-login |
- Removido na V2:
db_host,db_port,db_name,db_user,db_pass— a centralização depabast_senhas(decisão 0003) tornou a conexão por cliente desnecessária. Com ela saiu o uso do AES-256-GCM (db_passcifrada).
pabast_senhas — hashes de senha pAbast, no layout do ERP · sem PRIMARY KEY¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
varchar(7) | — | V3 | ChaveUsu do ERP; era PK, dropada na V6 | |
cliente_id |
varchar(50) | NOT NULL | V3 | default 'vantroba'; parte da chave natural lógica |
|
id_empresa |
varchar(4) | V3 | |||
id_usuario |
varchar(3) | V3 | usado no login (findByClienteIdAndIdUsuario) |
||
nome_usuario |
varchar(70) | V3 | |||
senha |
varchar(5) | V3 | hash pAbast (nunca texto claro) | ||
senha_compl |
varchar(5) | V3 | hash complementar pAbast | ||
status |
varchar(1) | V3 | |||
data_hora |
varchar(14) | V3 | AAAAMMDDHHMMSS do ERP (sem conversão) |
- Chave natural lógica
(cliente_id, id)— garantida em software (AdminController+PabastSenhaRepositorysempre olham o par). Sem constraint no banco desde a V6 (a mesma ChaveUsu aparece em clientes diferentes). Ver decisão 0008.
app_user_access — acesso do usuário ao app (pAbast ∪ oauth, unificado)¶
Evolui a antiga oauth_access_requests (V13, 016): uma tabela só para o acesso de qualquer
usuário — social (oauth) ou staff ERP (pАbast) — carregando status (lifecycle) + role.
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V13 | uuidv7() |
cliente_id |
varchar(50) | FK | NOT NULL | V13 | → clientes(cliente_id) |
app_id |
varchar(100) | NOT NULL | V13 | app do acesso | |
subject_type |
varchar(16) | UK | NOT NULL | V13 | CHECK oauth\|pabast |
subject_key |
varchar(128) | UK | NOT NULL | V13 | firebase_uid (oauth) | id/ChaveUsu (pabast, [I1] do 016) |
email |
varchar(320) | V13 | nullable: pАbast não tem email | ||
nome |
varchar(255) | V13 | exibição | ||
status |
varchar(32) | NOT NULL | V13 | lifecycle; default PENDENTE |
|
role |
varchar(50) | V13 | papel (catálogo app_roles); NULL exceto quando ATIVO |
||
created_at/updated_at |
timestamptz | NOT NULL | V13 | NOW() |
- UK
(subject_type, subject_key, cliente_id, app_id)— um acesso por usuário/cliente/app. - CHECK
status IN ('PENDENTE','REJEITADO','ATIVO','INATIVO'). SóATIVOemite JWT (claimroleverbatim, consumido pela sync rule do PowerSync); os outros 3 → sem acesso.INATIVO= soft-revoke. - oauth: row criada PENDENTE no 1º client-login; approve → ATIVO+role. pАbast: row criada só
na atribuição de papel (login não cria); sem row = sem papel = fail-closed (token pАbast sem
claim
role, mas login 200).emailNULL para pАbast. - Migração V13: as linhas de
oauth_access_requestsviraramsubject_type='oauth',subject_key=firebase_uid;oauth_access_requestsfoi dropada (prod tinha 0 linhas). subject_keyé sempre trimado.pabast_senhas.idvem do ERP com espaço na borda (1 0999) e o login compara comid.trim(); grant gravado com o id cru davamatch_login=0— o usuário via "não foi liberado" mesmo com papel atribuído. Os controllers trimam na escrita, e a V14 é um data-fix que normaliza o que já estava gravado (resolvendo antes eventual colisão de UK, pela linha deupdated_atmaior). Não há CHECK impondo isso: a constraint quebraria o app da versão anterior num rollback por redeploy.- Índice em
status.
oauth_access_grantsfoi DROPADA na V11 (ledger ocioso);oauth_access_requestsfoi renomeada/evoluída paraapp_user_accessna V13 (unificação pАbast+oauth, 016). O contrato admin oauth preserva a chave JSONfirebase_uid(=subject_keydas linhas oauth) para o central-ui.
oauth_access_grantsfoi DROPADA na V11. Era um ledger ocioso (o client-login sempre leu o papel deoauth_access_requests, nunca dos grants). O parstatus+roleemoauth_access_requests— comINATIVOcomo soft-revoke — a substitui. As classes Java (OauthAccessGrant/repo) e o teste correspondente saíram no mesmo PR.
app_roles — catálogo de papéis por app (data-driven) · PK (app_id, role)¶
Papéis que um app oferece (ex.: ADMIN, DIRETORIA, COMERCIAL, LUBRIFICANTE). Cresce sem
deploy via CRUD admin (POST /api/admin/apps/{app}/roles e DELETE /api/admin/apps/{app}/roles/{role})
ou, preferencialmente, pelo
POST /api/admin/apps/{app}/roles/sync, que ingere o roles-catalog.json declarado pelo próprio
app (upsert, nunca deleta — decisão 0025).
Schema criado vazio na V12 — o catálogo de cada app é populado por dado, antes do 1º approve
daquele app.
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
app_id |
varchar(100) | PK, FK | NOT NULL | V12 | → apps(app_id) |
role |
varchar(50) | PK | NOT NULL | V12 | machine-code do papel (uppercase); vai no claim role do JWT |
label |
varchar(100) | V12 | rótulo humano para o picker (central-ui / tela do app) |
apps — catálogo da Central de Apps (build-time) · PK natural app_id¶
Populado pelo broker (POST /api/setup/provision) a partir do app.json do app; fonte do
dashboard da central. Ver decisão 0010.
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
app_id |
varchar(100) | PK | NOT NULL | V7 | kebab; namespace único com os grants |
nome |
varchar(200) | NOT NULL | V7 | ||
descricao |
varchar(500) | V7 | |||
grupo |
varchar(20) | NOT NULL | V7 | CHECK xadm\|cliente |
|
cliente_id |
varchar(50) | FK | V7 | → clientes; NULL p/ xadm |
|
slug |
varchar(100) | V7 | link do portal de docs | ||
repo_url |
varchar(300) | V7 | link do source (Forgejo) | ||
production_url |
varchar(300) | V7 | |||
stack |
varchar(100) | V7 | |||
toolchain_json |
jsonb | V7 | ex. {"java":"25"} |
||
active |
boolean | NOT NULL | V7 | default true | |
created_at/updated_at |
timestamptz | NOT NULL | V7 |
app_provider_resources — recurso por (app, provider) · estado + refs¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V8 | uuidv7() |
app_id |
varchar(100) | FK | NOT NULL | V8 | → apps(app_id) |
provider |
varchar(20) | NOT NULL | V8 | CHECK glitchtip\|garage\|aptabase\|metabase ¹ |
|
state |
varchar(20) | NOT NULL | V8 | CHECK desligada\|provisionando\|ligada\|falha |
|
resource_ref |
jsonb | V8 | referência no provedor | ||
client_config |
jsonb | V8 | client-grade (DSN/key/bucket) | ||
secret_ref |
jsonb | V8 | {env_name, valor_cifrado, coolify_resource_ref} |
||
last_error |
text | V8 | |||
created_at/updated_at |
timestamptz | NOT NULL | V8 |
- UNIQUE
(app_id, provider); índice emapp_id. Recursos de build são por app (não por par); os "pares (cliente, app)" derivam dos grants. - ¹
metabaseestá reservado no CHECK (V8, congelada) mas foi descopado da featureanalytics— é BI independente, o app não o consome (decisão 0013). Providers vivos:glitchtip,garage,aptabase.
Re-ancoragem (V9):
oauth_access_grants.app_idganhou FK →apps(app_id)— os acessos de terceiros passavam a exigir o app catalogado.oauth_access_requestsnão recebe FK (o firebase client-login cria pedidos para apps ainda não catalogados). O backfill V9 cataloga umappsporapp_iddistinto dos acessos (grupo='cliente', curadoria posterior). Nota (V11): com o drop deoauth_access_grants, essa FK saiu junto; o catálogoapp_roles(V12) é que agora referenciaapps(app_id).
Control-plane de deploy¶
Tabelas do control-plane de deploy M2M (decisão 0021,
0022, 0026).
Não têm FK para apps — a âncora é o recurso Coolify real (slug de imagem ou de git repo), que
pode divergir do app_id do catálogo; a referência é mole, por metadado, de propósito (imune a
rebrand do catálogo).
app_deploy_targets — mapa reconciliado app → recurso Coolify¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V10 | uuidv7() |
registry_slug |
varchar(100) | UK¹ | NOT NULL | V10 | último segmento do docker_registry_image_name (recurso por imagem) ou do git repo, sem .git (recurso compose — V15) |
target |
varchar(20) | UK¹ | NOT NULL | V10 | CHECK jar\|native\|web (V10) + compose (V15). Prefixo da tag <target>-amd64; compose é fixo para build-from-git |
instance |
varchar(100) | UK¹ | V10 | do FQDN int.<cli>.xadm.biz ou int-jar.<cli>.xadm.biz (o jar da troca, mesma instância) → <cli>; NULL p/ app 1:1 e canary |
|
coolify_uuid |
varchar(100) | NOT NULL | V10 | alvo do POST /deploy?uuid= |
|
fqdn |
varchar(300) | V10 | primeiro FQDN do recurso (auditoria/diagnóstico) | ||
created_at/updated_at |
timestamptz | NOT NULL | V10 |
- ¹ UNIQUE de expressão
uq_app_deploy_targets (registry_slug, target, COALESCE(instance,''))— tratainstanceNULL como''para virar alvo doON CONFLICTdo upsert do reconcile. Uma linha lógica = um recurso deployável. - A tabela é derivada, nunca escrita à mão: o
DeployReconcileServicea recompõe a partir deGET /applicationsdo Coolify (on-boot, on-demand e on-miss com debounce). Umcoolify_uuidnovo por cutover blue-green re-ancora a mesma linha; um slug novo cria linha nova. Perder a tabela é inócuo — o próximo reconcile a reconstrói.
deploy_audit — uma linha por recurso disparado¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V10 | uuidv7() |
token_ref |
varchar(100) | V10 | referência não-reversível ao token (sha256:<12 hex>) — nunca o token cru |
||
registry_slug |
varchar(100) | V10 | âncora resolvida | ||
target |
varchar(20) | V10 | sem CHECK (auditoria registra o que veio, inclusive valor recusado) | ||
instance |
varchar(100) | V10 | |||
coolify_uuid |
varchar(100) | V10 | recurso disparado | ||
deployment_uuid |
varchar(100) | V10 | devolvido pelo Coolify; NULL se o disparo falhou | ||
resultado |
varchar(20) | V10 | CHECK ok\|parcial\|erro |
||
criado_em |
timestamptz | NOT NULL | V10 |
- Índice
idx_deploy_audit_slug_target (registry_slug, target). Append-only — nada apaga.
Heartbeat do integrador-client¶
Estado atual de cada instalação do integrador-client (jar on-premise), alimentado pelo heartbeat que o integrador-server do cliente repassa (decisão 0028). Sem histórico: só o último heartbeat interessa para "está vivo?".
integrador_instancias — uma instalação por cliente · PK cliente_id¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
cliente_id |
varchar(50) | PK, FK | NOT NULL | V16 | → clientes(cliente_id), sem ON DELETE CASCADE (a exclusão de cliente é lógica) |
versao |
varchar(40) | NOT NULL | V16 | versão do jar no último heartbeat | |
fluxos |
text[] | NOT NULL | V16 | zim.fluxos ativos; default {} |
|
hostname |
varchar(255) | V16 | máquina do client; NULL quando ele não resolve | ||
motivo |
varchar(20) | V16 | inicio|periodico — validado na API, sem CHECK |
||
iniciado_em |
timestamptz | V16 | início do processo do client; o offset original não é guardado | ||
primeiro_heartbeat_em |
timestamptz | NOT NULL | V16 | 1º heartbeat — o upsert nunca o toca | |
ultimo_heartbeat_em |
timestamptz | NOT NULL | V16 | relógio do central, nunca o do client | |
alertado_em |
timestamptz | V16 | não nulo = episódio de silêncio aberto (o job alertou); o próximo heartbeat zera |
- Carimbos sem
DEFAULT now(): quem carimba é a aplicação, com oClockinjetado — um relógio só para gravar, reivindicar e contar horas úteis. - Escrita por upsert (
INSERT … ON CONFLICT (cliente_id) DO UPDATE), sem transação: a linha nasce no 1º heartbeat (registro automático) e cada heartbeat atualiza tudo menosprimeiro_heartbeat_em, zerandoalertado_em. - Claim do job de silêncio:
UPDATE … SET alertado_em = :agora WHERE alertado_em IS NULL AND ultimo_heartbeat_em = :visto— só uma réplica vira a linha, e um heartbeat que chegou depois da leitura faz o claim falhar. - Remoção só pelo
DELETE /api/admin/integradores/{clienteId}("parar de acompanhar"); se o jar ainda roda, o próximo heartbeat recria a linha.
4. Mapeamento entrada↔dados¶
| Entrada | Lê / escreve | Resultado |
|---|---|---|
| Login pAbast | lê clientes (audience, active) + pabast_senhas (hash) |
JWT scope=full |
| Token anônimo | lê clientes (audience, active) |
JWT scope=pabast_only (depreciado) |
| Firebase xadm | valida token no Google; não lê banco | JWT scope=admin, xadm_admin=true |
| Firebase client-login | lê clientes (auth_methods, active); lê/grava oauth_access_requests |
JWT scope=full se aprovado; senão cria pedido + 403 |
| Refresh | valida o JWT; lê clientes + pabast_senhas (pAbast) + app_user_access |
JWT novo com status e papel atuais; 403 se revogado ou além do teto de sessão |
| Admin OAuth | lê/atualiza oauth_access_requests.status |
aprova/rejeita/altera papel |
| Heartbeat do integrador-client | lê clientes; upsert em integrador_instancias |
204; o 1º heartbeat registra a instalação |
| Job de silêncio (a cada 15 min) | lê integrador_instancias ⋈ clientes; marca alertado_em |
1 evento no GlitchTip por episódio |
| Admin integradores | lê integrador_instancias ⋈ clientes; DELETE apaga a linha |
lista com situacao / parar de acompanhar |
O audience de cada cliente entra no claim aud do JWT e deve casar com o
client_auth.audience do PowerSync daquele cliente (cap. 6).
5. Fluxos e processamento¶
Componentes¶
AuthController— emissão de tokens e JWKS. CORS em todos os endpoints, preflightOPTIONSnos POST, rate limiting por IP.JwtService— carrega o par RSA no@PostConstruct; emite os JWT (issueToken,issueAnonToken,issueXadmAdminToken,issueOauthFirebaseUserToken) e expõe o JWKS comalg=RS256.JwtValidator+AdminAuthFilter(@ServerFilter({"/api/admin/**", "/test/glitchtip"})) — validam o JWT interno e barram com 401/403 sexadm_adminfor falso (decisão 0032).- Controllers finos, regra nos services da feature (
ClienteAdminService,UsuariosDoAppService,IntegradorInstanciaService…): controller não injeta repository, e oArchitectureTesttrava essa fronteira — detalhe no guia do código. FirebaseTokenValidator— valida Firebase ID Tokens pelo JWKS do Google; é lazy (só existe seAUTH_FIREBASE_PROJECT_IDestiver configurado).PabastPasswordHasher— porta literal do algoritmo do integrador/Flutter.RateLimiter— janela fixa de 60 s por IP.AdminController+OauthFirebaseAdminController— CRUD de clientes e usuários pAbast, e a fila de aprovação OAuth.AppSelfServiceController(/api/apps/**) — auto-governança: o ADMIN do cliente aprova acesso e atribui papel dentro do seu app.CorsOrigins+AdminApiCorsFilter/AppApiCorsFilter— as duas superfícies de browser. A allowlist aceitahttps://*.xadm.biz(todo app cliente no domínio da casa) além da lista base e da env de ambiente; os filtros correm emHIGHEST_PRECEDENCEpara o preflightOPTIONS, que não levaAuthorization, não tomar 401 do filtro de auth.RolesCatalogClient+RolesCatalogSyncService— ingerem o catálogo de papéis que o app declara (upsert, nunca deleta).HeartbeatController+IntegradorAuthFilter(@ServerFilter("/api/integrador/**"), tokenCENTRAL_API_TOKEN) — recebem o heartbeat do integrador-client;SilencioJobalerta o silêncio;IntegradoresAdminControllerlista as instalações para o central-ui.
Heartbeat do integrador-client e alerta de silêncio¶
O integrador-client roda on-premise, um por cliente, e não tinha como avisar que parou — o sintoma
chegava dias depois como "a integração parou". Agora cada instalação manda um heartbeat no startup e a
cada 60 min ao integrador-server do próprio cliente, que carimba o cliente_id e o repassa ao central
(decisão 0028). O central faz upsert de uma
linha por cliente em integrador_instancias: o 1º heartbeat registra a instalação sozinho, sem
cadastro além do cliente existir em clientes.
O SilencioJob roda a cada 15 min (o primeiro tick só 20 min depois do boot, fora da janela do
smoke pós-deploy) e conta as horas úteis — 07:00–22:00, seg–sex, sem feriado nacional, em
America/Sao_Paulo — desde o último heartbeat de cada instalação sem episódio aberto e de cliente
ativo. Passou de 6h úteis, ele reivindica o episódio com um UPDATE condicional (só vira se
ninguém alertou e nenhum heartbeat chegou depois da leitura — com duas réplicas, uma só alerta) e
manda um evento ao GlitchTip do central, com fingerprint por cliente. O próximo heartbeat encerra o
episódio. Cliente desativado no central não alerta, mas a instalação segue aceita e listada como
cliente_inativo.
Um relógio só — o Clock injetado — carimba o heartbeat, reivindica o episódio e mede as horas.
Login pAbast (fluxo principal)¶
O app valida o formato localmente, chama POST /api/auth/pabast/login, e o
serviço: confere cliente ativo em clientes → busca o usuário por
(cliente_id, id_usuario) em pabast_senhas → confere os hashes com
PabastPasswordHasher.matches() → emite JWT scope=full com aud = audience do
cliente. Mitigação de timing: quando o usuário não existe, o serviço ainda
executa o matches() com valores fictícios, para não vazar "existe vs. não
existe" pela diferença de tempo.
Login Firebase de app terceiro (com aprovação)¶
Acesso e papel são eixos separados (decisão
0023): o status diz
se entra, o role diz o que vê. Só ATIVO emite JWT.
stateDiagram-v2
[*] --> PENDENTE: 1º client-login (cria pedido) → 403
PENDENTE --> ATIVO: admin aprova (com um role do catálogo)
PENDENTE --> REJEITADO: admin rejeita → 403
ATIVO --> INATIVO: admin revoga (soft-revoke) → 403
INATIVO --> ATIVO: admin reativa
ATIVO --> [*]: client-login emite JWT scope=full + claim role
No client-login, contas do domínio xadm são super-admin implícito (JWT
role=ADMIN direto, sem entrar na fila). O cliente precisa estar ativo e ter
oauth_firebase em auth_methods. O serviço procura o acesso por
(subject_type='oauth', subject_key=firebase_uid, cliente_id, app_id) em
app_user_access: ATIVO → emite JWT com o role verbatim (é o que a sync
rule do PowerSync consome); REJEITADO/INATIVO → 403 sem reabrir a fila;
PENDENTE ou inexistente → cria/atualiza o pedido e retorna 403.
Todo 403 carrega os admins do (cliente, app) no corpo — o usuário ainda não
tem JWT, logo não pode consultar o admins-contato, e a tela "Acesso Pendente"
precisa dizer a quem pedir liberação.
O role só pode ser um do catálogo do app (app_roles); atribuir fora dele é
422. O catálogo é declarado pelo próprio app e ingerido pelo central (decisão
0025) — a curadoria
manual driftava em silêncio.
A fila é gerenciada por dois caminhos: a equipe xadm por
/api/admin/oauth/firebase/requests, e o ADMIN do próprio cliente por
/api/apps/** (auto-governança). Usuário pAbast entra no mesmo modelo com
subject_type='pabast' — sem linha, sem papel, fail-closed (decisão
0024).
Barrar não exige conceder. Os dois lados aceitam INATIVO/REJEITADO/PENDENTE
sem papel — oauth por /usuarios/oauth/{id}/status, pAbast por
/usuarios/pabast/{id}/status (que cria a linha de acesso se ainda não existir). Só
ATIVO exige papel (422 role_required), porque o token pAbast copia o role
apenas de linha ATIVA: ativar sem papel devolveria sessão role-less. Inativar
preserva o papel já concedido, então reativar não obriga a re-escolher.
Refresh do token (renovação sem senha)¶
Sem refresh, o papel de uma sessão ficava congelado até o JWT vencer: a sessão pAbast guarda a senha só em
memória, e depois de um F5 o app não tinha como re-logar. Troca de papel e inativação só apareciam com
logout/login (incidente do bi-comercial, 2026-09-15). O POST /api/auth/refresh troca um JWT de app-user
ainda válido por um novo, com a mesma decisão do login sem re-provar a credencial (decisão
0034, ADR central 0037):
- valida o JWT (assinatura +
exp) e exigeoauth_app_ideoauth_cliente_id; - confere o teto de sessão:
agora − auth_time(ou oiat, em token antigo) acima deAUTH_SESSION_MAX_HOURS→403 session_expired; - exige cliente ativo (apagado conta como inativo) e com o método de auth do sujeito;
- classifica o
subpelo claimsub_type; em token de antes dele, peloemail— o token pAbast nunca o carrega, o oauth sempre; - pAbast: o
subprecisa existir nopabast_senhasdo cliente, comparado comTRIMporque o ERP entrega oidcom espaço na borda; senão,403 access_revoked. O papel sai do acessoATIVO, e sem ele o token sai semrole, igual ao login. oauth: sóATIVOrenova;@xadmsegueADMIN; - emite com o
auth_timecopiado e oexpcortado no teto.
Como a senha não é re-provada, senha trocada no ERP não derruba a sessão antes do teto (7 dias, o máximo da
norma). O refresh também não revoga o token anterior — o PowerSync o aceita até o exp —, então o prazo
de revogação no servidor é o TTL do access token.
Token anônimo — depreciado¶
POST /api/auth/anon emite JWT scope=pabast_only (exp 1 h) e existe apenas
para compatibilidade com o bi-transporte: pabast_senhas não é mais
sincronizado via PowerSync, então o bootstrap-offline que esse token habilitava
deixou de ser o caminho corrente. Não usar em integrações novas.
Rate limiting¶
RateLimiter mantém um contador por IP (extraído de X-Real-IP/X-Forwarded-For
atrás do Traefik) e bloqueia acima de 60 req/min, resetando todos os contadores
a cada 60 s (@Scheduled). É in-memory e single-instance — suficiente para o
volume atual (decisão 0005).
Excesso → 429 com Retry-After: 60.
6. Conceitos transversais e configuração¶
JWT RS256 + JWKS é o conceito central: a chave privada assina (só o
central-backend a tem); a pública sai no /.well-known/jwks.json para qualquer
serviço verificar. O campo alg=RS256 é obrigatório no JWKS — o PowerSync
recusa chaves sem ele, e o nimbus-jose-jwt não o inclui por padrão (decisão
0002).
Algoritmo pAbast é porta literal do integrador e do app Flutter — não alterar, sob pena de quebrar a compatibilidade com os hashes em produção.
AES-256-GCM cifrava a senha do banco de cada cliente (clientes.db_pass);
legado após a centralização (decisão
0003), que removeu as colunas de
conexão por cliente.
Configuração (variáveis do projeto):
| Tema | Variáveis principais |
|---|---|
| Ambiente | MICRONAUT_ENVIRONMENTS (prod), PORT (default 8080) |
Banco db_auth |
DATASOURCES_DEFAULT_URL, DB_USER, DB_PASSWORD |
| Chaves / JWT | AUTH_PRIVATE_KEY (RSA PKCS#8 base64) ou AUTH_PRIVATE_KEY_PATH, AUTH_ISSUER, AUTH_KEY_ID (default auth-1), AUTH_TOKEN_EXPIRATION_MINUTES (default 1440), AUTH_SESSION_MAX_HOURS (teto da sessão de app-user, default e máximo 168) |
| Firebase (admin/OAuth) | AUTH_FIREBASE_PROJECT_ID, AUTH_XADM_EMAIL_DOMAIN (default xadm.com.br) |
| Central de Apps | AUTH_CIPHER_KEY (AES-256, 32 bytes base64) — cifra o secret da write-key do Garage; obrigatória quando a feature arquivos/Garage é provisionada |
| M2M (deploy e integradores) | CENTRAL_DEPLOY_TOKEN (/api/ci/**, do CI) · CENTRAL_API_TOKEN (/api/integrador/**, dos integrador-servers — heartbeat) — os dois obrigatórios em prod: vazios, o boot é recusado |
| CORS | CENTRAL_CORS_EXTRA_ORIGINS (CSV) — só para origem fora de *.xadm.biz, que a allowlist já cobre; vazio em produção |
RESEND_API_KEY (produção) · RESEND_REMETENTE · RESEND_ENDPOINT (default a API da Resend; existe como seam de teste) · CENTRAL_EMAIL_SMTP_HOST/_PORT/CENTRAL_EMAIL_FROM (mock de teste) |
A geração de chaves, o deploy e a configuração do PowerSync são procedimento — ficam nos runbooks de Operação, não neste livro.
7. Contratos públicos / API¶
A conclusão de tudo acima: as assinaturas que outros sistemas consomem.
| Método / rota | Auth | Resultado |
|---|---|---|
GET /.well-known/jwks.json |
nenhuma | chave pública RSA (alg=RS256) p/ o PowerSync |
POST /api/auth/pabast/login |
nenhuma | 200 JWT scope=full · 400/401/403/429 |
POST /api/auth/firebase/login |
Firebase ID Token | 200 JWT scope=admin (só @xadm.com.br) · 403/503 |
POST /api/auth/firebase/client-login |
Firebase ID Token | 200 JWT scope=full se aprovado · 403 pendente/negado |
POST /api/auth/refresh |
JWT de app-user vigente | 200 JWT novo com status e papel atuais · 401/403/429 |
POST /api/auth/anon?cliente=X |
nenhuma | 200 JWT scope=pabast_only (depreciado) |
/api/admin/** |
JWT xadm_admin (ou shared secret do integrador) |
CRUD de clientes/usuários, fila de aprovação OAuth e catálogo de papéis (incl. POST .../roles/sync) |
POST /api/integrador/heartbeat |
Bearer CENTRAL_API_TOKEN |
heartbeat do integrador-client (via integrador-server) · 204 · 400/401/422 |
GET /api/admin/integradores · DELETE /api/admin/integradores/{clienteId} |
JWT xadm_admin |
instalações do integrador-client com situacao / parar de acompanhar (idempotente, 204) |
/api/apps/** |
JWT de app-user (oauth_app_id) |
auto-governança do cliente: aprovar acesso, atribuir papel, contato do admin |
GET /health |
nenhuma | liveness plano {status, versao, flavor, commit} da xadm-comum-web (200 fixo). flavor = native|jvm; commit = o XADM_COMMIT da imagem, omitido quando ausente (ou quando vem o HEAD que o Coolify injeta). É o que o smoke pós-deploy usa como identidade da entrega (0027) |
O contrato narrado (exemplos curl, corpos, códigos) está em
dev/api-rest; o Javadoc interno é gerado pelo CI de
docs em dev/api/.
8. Apêndice — Histórico (changelog)¶
Como o sistema chegou ao estado atual — as decisões e mudanças que o fundamentaram.
Decisões:
- Framework: Micronaut 4 + Netty
- JWT RS256 com nimbus-jose-jwt
- pabast_senhas centralizado em db_auth
- Login Firebase para administradores xadm e usuários de apps terceiros
- Rate limiting: janela fixa in-memory
- Bootstrap offline com token anônimo (scope=pabast_only)
- Admin CRUD protegido por JwtValidator em filtro dedicado
- pabast_senhas sem PRIMARY KEY — unicidade em software por (cliente_id, id)
- Bump para Micronaut 5 e JDK 25
- Central de Apps — modelo build-time (catálogo + broker de provisão)
- Observabilidade de erros (GlitchTip) e resposta honesta do /provision
- Garage (arquivos): provisão de bucket+key e corretor de presign broker-signed
- Analytics (Aptabase): auto-provisão forjando o token de sessão (HS256 + AUTH_SECRET)
- Analytics BI (Metabase): auto-provisão de dashboards via API REST (re-escopo)
- Filtros admin com @ServerFilter (honra @Order)
- Contrato de erro único (RFC 7807 / problem+json)
- Metabase signed embedding (supersede o "BI = só link" da 0014)
- Habilitação GraalVM native-image do auth
- Monitor de host/apps do Coolify (Fase 1, read-only)
- Rename auth → central-backend (identidade interna) mantendo auth.xadm.biz como protocolo
- Adoção do control-plane de deploy (ADR central 0026) com resolução ancorada na imagem
- Self-deploy do central pelo próprio /api/ci/deploy — reverte a guarda self_deploy_refused
- Login social: modelo status×role e auto-governança de acesso por app
- Acesso unificado do usuário ao app (pAbast ∪ oauth), fail-closed
- Catálogo de papéis declarado pelo app e ingerido pelo central
- Apps compose (build-from-git) no control-plane, ancorados no slug do git repo
- Adota o smoke de produção pós-deploy e a identidade de build (XADM_COMMIT)
- Heartbeat do integrador-client, alerta de silêncio por horas úteis e CENTRAL_API_TOKEN
- Adoção do CI/CD 100% GitHub Actions (ADR central 0027) — pipeline.yml único
- Adoção do native por padrão (ADR central 0033) — alvo único native
- Adoção das libs da casa na versão corrente (ADR central 0034)
- Rota de teste do GlitchTip sob o JWT xadm_admin
- Dois tokens de serviço para o central, um por escopo de rota
- Refresh do JWT por token e teto absoluto de sessão
Operação
Gerar as chaves e segredos do central-backend¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30
Segredos
Gere estes valores numa máquina segura (não no servidor de produção) e guarde a chave privada em cofre (Bitwarden/1Password). Vazou → rotacione (ver Rotação de chaves RSA).
O que é¶
Os segredos que o serviço precisa antes do primeiro deploy: o par de chaves RSA que assina os JWT e (legado) a chave AES.
Quando usar¶
- Antes do primeiro deploy de um ambiente novo.
- Ao provisionar um ambiente de staging/teste isolado.
Pré-requisitos¶
opensslna máquina local.
Passos¶
-
Chave privada RSA-2048 (PKCS#8):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out auth_private.pem openssl pkey -in auth_private.pem -pubout -out auth_public.pem -
Converter a privada para base64 de linha única (valor de
AUTH_PRIVATE_KEY):base64 -w0 auth_private.pem > auth_private_b64.txt -
No Coolify, defina as variáveis do serviço:
AUTH_PRIVATE_KEY= conteúdo deauth_private_b64.txtAUTH_KEY_ID= identificador da chave (ex.auth-1); precisa ser único por chaveAUTH_ISSUER=https://auth.xadm.biz
Verificação¶
-
Após o deploy, o JWKS expõe a chave pública com
alg=RS256:curl -sf https://auth.xadm.biz/.well-known/jwks.json | jq '.keys[0] | {alg, kid}' # Esperado: {"alg":"RS256","kid":"auth-1"}
Reversão¶
Trocar a chave depois de em uso exige coordenação com o PowerSync (que cacheia o JWKS) — siga o runbook de Rotação de chaves RSA.
AES (AUTH_CIPHER_KEY) — obrigatória quando a Central de Apps provisiona Garage
A chave AES-256 (openssl rand -base64 32, exatamente 32 bytes em base64) cifrava a
senha do banco de cada cliente em clientes.db_pass. Após a centralização de
pabast_senhas (decisão 0003) essas
colunas foram removidas — mas a chave voltou a ser usada pela Central de Apps: o
GarageProvider cifra com ela o secret_access_key da write-key do bucket
(decisão 0012). Ausente ou vazia,
o provisionamento de Garage é recusado antes de criar qualquer recurso (erro claro,
sem bucket/key órfãos). Gere-a em qualquer ambiente que for provisionar arquivos.
Provisionar o banco db_auth e cadastrar clientes¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30
O que é¶
Cria o banco db_auth e o usuário do serviço, e cadastra o primeiro cliente e seus
usuários pAbast pela API administrativa. Complementa o deploy e a
geração de chaves.
Quando usar¶
- Setup de um ambiente novo, antes do primeiro login.
- Onboarding de um cliente novo (só os passos 3–4).
Pré-requisitos¶
- PostgreSQL 18+ acessível.
- Para o cadastro via API: o serviço no ar e um token admin (login Firebase
@xadm.com.br, ver passo 2).
Passos¶
1. Criar o banco e o usuário¶
CREATE DATABASE db_auth;
CREATE USER auth_service WITH PASSWORD 'senha_segura_aqui';
GRANT CONNECT ON DATABASE db_auth TO auth_service;
\c db_auth
GRANT USAGE ON SCHEMA public TO auth_service;
GRANT CREATE ON SCHEMA public TO auth_service; -- o Flyway cria as tabelas no startup
O Flyway roda no startup e aplica V1..V6 (cria clientes, pabast_senhas,
oauth_access_requests, oauth_access_grants). As variáveis de conexão são
DATASOURCES_DEFAULT_URL, DB_USER, DB_PASSWORD (ver deploy).
2. Obter um token admin (Firebase)¶
Requer AUTH_FIREBASE_PROJECT_ID configurado. Com o Firebase ID Token de um usuário
@xadm.com.br:
curl -sf -X POST https://auth.xadm.biz/api/auth/firebase/login \
-H "Authorization: Bearer $FIREBASE_TOKEN" | jq -r .access_token
Guarde o access_token como ADMIN_TOKEN.
3. Cadastrar o cliente¶
curl -sf -X POST https://auth.xadm.biz/api/admin/clients \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{ "cliente_id": "vantroba", "audience": "powersync-vantroba",
"auth_methods": ["pabast"] }' | jq .
Adicione "oauth_firebase" em auth_methods se o cliente for usar login Google de
apps terceiros.
4. Cadastrar usuários pAbast¶
curl -sf -X POST https://auth.xadm.biz/api/admin/pabast/users \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{ "cliente_id": "vantroba", "id_usuario": "123",
"nome_usuario": "João Silva", "senha": "1234", "senha_compl": "567" }' | jq .
As senhas são hasheadas no servidor — nunca ficam em texto claro.
Verificação¶
SELECT cliente_id, active, auth_methods FROM clientes;
SELECT cliente_id, id_usuario, nome_usuario FROM pabast_senhas;
SELECT * FROM flyway_history_auth ORDER BY installed_rank;
E um login real fim-a-fim conforme o deploy.
Reversão¶
Remover um cliente: DELETE /api/admin/clients/{clienteId}. Remover um usuário:
DELETE /api/admin/pabast/users/{clienteId}/{id}. Destrutivo — confirme o
clienteId antes.
Deploy do central-backend no Coolify¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
O que é¶
Sobe (ou atualiza) o serviço auth.xadm.biz no Coolify, com HTTPS via Traefik e
acesso ao db_auth na rede interna.
Quando usar¶
- Primeiro deploy do serviço num ambiente.
- Mudança de variável de ambiente (chaves, banco, Firebase).
- O deploy de código não precisa deste runbook: é automático pelo control-plane — ver Deploy de código abaixo.
Pré-requisitos¶
- Chaves geradas (Gerar as chaves e segredos).
db_authacessível na rede do projetointegracao-compartilhado.- Acesso ao painel do Coolify.
Passos¶
-
Criar o recurso (primeira vez): Coolify → projeto
integracao-compartilhado→ New Resource → Docker Image, apontando pra imagem native que o CI publica (fonte.xadm.biz/xadm/central-backend:native-amd64; o app é native-only — decisão 0030).- Port:
8080 - Network: a rede compartilhada onde o
db_authestá acessível - Domains:
auth.xadm.bizcom Force HTTPS (Traefik emite o certificado) — é o host do issuer/JWKS e não muda (decisão 0020) —, maiscentral-backend.xadm.biz, o domínio estável do serviço (é oCENTRAL_DEPLOY_URLque o CI chama).
Build Pack = Docker Image não é preferência, é requisito do control-plane
O reconcile do
app_deploy_targetssó ancora recurso combuild_pack=dockerimage, derivando oregistry_slugdo nome da imagem e otargetdo prefixo da tag (0021). Recurso buildado do git (Dockerfile/nixpacks) não entra no mapa → oPOST /api/ci/deployresponde404 no_deploy_targete o jobdeploydopipeline.ymlquebra. A única outra família ancorada ébuild_pack=dockercompose, pelo slug do git repo comtarget='compose'(0026) — não é o caso deste serviço. - Port:
-
Variáveis de ambiente (mínimo de produção):
MICRONAUT_ENVIRONMENTS=prod DATASOURCES_DEFAULT_URL=jdbc:postgresql://postgresql:5432/db_auth DB_USER=auth_service DB_PASSWORD=<senha-do-db_auth> AUTH_PRIVATE_KEY=<base64 da chave privada> AUTH_KEY_ID=auth-1 AUTH_ISSUER=https://auth.xadm.biz # Cai para ≤ 60 (ADR central 0037) só depois que os apps que renovam sessão tiverem o refresh; # antes disso, app sem refresh re-loga a cada TTL (decisão 0034): AUTH_TOKEN_EXPIRATION_MINUTES=1440 # Opcional — teto da sessão de app-user desde o último login com credencial (refresh, decisão 0034); # máximo 168 (7 dias, ADR central 0037) — valor acima é cortado: AUTH_SESSION_MAX_HOURS=168 # Opcional — habilita login Firebase (admin e client-login): AUTH_FIREBASE_PROJECT_ID=<projeto-firebase> AUTH_XADM_EMAIL_DOMAIN=xadm.com.br # Opcional — notificação de novo acesso pendente (sem a chave, o envio é no-op): RESEND_API_KEY=<chave-resend> # Shared secret do integrador p/ /api/admin/**: CENTRAL_INTEGRATOR_TOKEN=<segredo> # Do CI p/ /api/ci/** — OBRIGATÓRIO: ausente ou vazio, o container NÃO SOBE # (DeployTokenGuard, ativo só com MICRONAUT_ENVIRONMENTS=prod — acima; o rolling # deploy mantém o anterior no ar). Mesmo valor no secret do GitHub. CENTRAL_DEPLOY_TOKEN=<segredo> # Dos integrador-servers p/ /api/integrador/** (heartbeat do integrador-client) — # OBRIGATÓRIO pelo mesmo motivo: ausente ou vazio, o container NÃO SOBE # (IntegradorTokenGuard, só em prod). MESMO valor do CENTRAL_API_TOKEN dos # integrador-servers. Só abre # /api/integrador/** — não tem relação com o CENTRAL_INTEGRATOR_TOKEN acima. CENTRAL_API_TOKEN=<segredo>Heartbeat, alerta de silêncio e o lado do integrador-server: runbook do heartbeat.
CORS. Não precisa configurar nada para app da casa: a allowlist já cobre
https://*.xadm.biz(todobi.<cliente>.xadm.biz) além docentral/logine das portas de dev.CENTRAL_CORS_EXTRA_ORIGINS(CSV) existe só para origem fora do domínio da casa — staging local em porta própria, por exemplo. Vazio em produção.E-mail. Produção usa Resend (
RESEND_API_KEY, remetente emRESEND_REMETENTE). O endpoint é property (RESEND_ENDPOINT, default a API da Resend) e existe para o teste apontar o transporte a um mock — não mexer em produção. O transporte SMTP (CENTRAL_EMAIL_SMTP_HOST,CENTRAL_EMAIL_SMTP_PORT,CENTRAL_EMAIL_FROM) existe para ambiente de teste com mock (Mailpit no e2e) — sem auth nem TLS, não aponte para um SMTP real. Com as duas configs setadas, o Resend vence (é@Primary). -
Health check:
GET /healthna porta8080(o Traefik só roteia após200). O Flyway e oJwtServicefalham-fast no startup — config errada não sobe. É o/healthflat doxadm-comum-web({status, versao, flavor, commit}, liveness, 200 fixo): o health domicronaut-managementestá desligado (endpoints.health.enabled=falsenoapplication.yml), então indicador de banco não faz o check oscilar. Ver decisão 0016. -
Deploy: salve e dispare o deploy pela UI. Acompanhe os logs até
Startup completed. Só este primeiro deploy é manual — do segundo em diante quem dispara é o CI.
Deploy de código (o fluxo normal)¶
Não há webhook do Coolify nem ponte SSH: o deploy de código passa pelo control-plane (0021), e o central deploya a si mesmo por ele (0022).
- Push em
master(ou tagv*) dispara opipeline.ymlno GitHub Actions. - O
build_nativeroda em paralelo ao gate e publica duas tags:fonte.xadm.biz/xadm/central-backend:native-amd64(móvel, a que o deploy consome) e…:<sha>-native(imutável, o alvo de rollback do smoke). O build recebe--build-arg XADM_COMMIT=<sha>, que oDockerfile.nativepromove aENVe axadm-comum-webemite comocommitno/health— é o que prova que o container no ar é o desta entrega. - Só com o gate verde (e, em tag, o
release-check), o jobdeployfazPOST $CENTRAL_DEPLOY_URL(https://central-backend.xadm.biz/api/ci/deploy— flavor-agnóstica: URL que outro sistema consome não carrega flavor de build, constituição, Entrega) comAuthorization: Bearer $CENTRAL_DEPLOY_TOKENe{"target":"native","image":"fonte.xadm.biz/xadm/central-backend:native-amd64"}. Antes do POST ele registra ocommitque está no ar — depois do POST já é o novo. - O central-atual (no ar, dentro da casa) resolve o próprio recurso no
app_deploy_targetse chama a API do Coolify localmente — essa API é restrita ao IP da casa e o runner do GitHub não a alcança; é por isso que o POST passa pelo central e não pelo runner. -
O Coolify faz rolling deploy health-gated: a imagem nova sobe, passa no
/health→ substitui; falha → a anterior segue no ar. -
O job
smokeroda depois do deploy (que é assíncrono: o POST retorna antes de o container novo servir) e é o gate de produção — ver decisão 0027 e a norma da casa. Ele afirma que ocommitdo/healthé o desta entrega e que as rotas dedocs/app.json→smoke.routesrespondem o status declarado. Reprovou → o pipeline fica vermelho, e o relatório dá o alvo de reversão…:<sha anterior>-<target>. A reversão automática está pendente no control-plane: oPOST /api/ci/deployaceita só a tag móvel e responde400 invalid_imageà imutável, então quem reverte é o operador (seção Reversão, abaixo).
A auto-referência não trava porque o central-atual está no ar no disparo e a chamada ao Coolify enfileira e retorna antes do swap do container.
Break-glass — central fora do ar
Sem central no ar não há quem receba o POST, então o deploy automático não acontece
(o job falha no curl -fsS). Suba manualmente pela UI do Coolify, de dentro da casa:
recurso native → Redeploy (ou aponte a tag imutável da imagem boa). É cenário de incidente — o
caminho normal cobre todo release.
Verificação (go-live)¶
O job smoke já roda os dois primeiros itens automaticamente (são as rotas de smoke.routes).
Os manuais abaixo servem para incidente e para o primeiro deploy, que é manual.
# 1. Serviço no ar — e QUAL entrega/flavor está no ar
curl -sf https://central-backend.xadm.biz/health | jq '{status, versao, flavor, commit}'
# status "UP"; flavor "native"|"jvm"; commit = o sha da entrega esperada.
# `commit` ausente = imagem buildada sem --build-arg XADM_COMMIT (ou lib < 0.9.0).
# `commit: "HEAD"` = imagem AINDA no nome antigo (SOURCE_COMMIT), que o Coolify sobrescreve
# em runtime. Nao e identidade: rebuilde com XADM_COMMIT (lib >= 0.9.0 ja o omite).
# 2. JWKS com alg=RS256 e o kid esperado
curl -sf https://central-backend.xadm.biz/.well-known/jwks.json | jq '.keys[0] | {alg, kid}'
# 3. Login pAbast válido → 200 + JWT scope=full
curl -sf -X POST https://auth.xadm.biz/api/auth/pabast/login \
-H "Content-Type: application/json" \
-d '{"cliente":"vantroba","id_usuario":"123","senha":"1234","senha_compl":"567"}' \
| jq '{tem_token: (.access_token != null), scope}'
# 4. Credenciais inválidas → 401
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://auth.xadm.biz/api/auth/pabast/login \
-H "Content-Type: application/json" \
-d '{"cliente":"vantroba","id_usuario":"123","senha":"0000","senha_compl":"000"}'
Logs relevantes¶
INFO ... Login pAbast OK: id_usuario=... cliente=...
WARN ... Rate limit atingido: ip=...
ERROR ... Falha ao conectar ao banco
Configure alerta (Coolify/Uptime Kuma) para /health ≠ UP.
Reversão¶
- Código — rollback automático: pendente no control-plane. O job
smokereprova, calcula o alvo…:<sha anterior>-<target>(ocommitlido do/healthantes do deploy) e pede a reversão aoPOST /api/ci/deploy, que aceita só a tag móvel<target>-amd64e responde400 invalid_imageà imutável. O relatório sai comROLLBACK FALHOU no control-planee o alvo. - Código, manual (o caminho de hoje): no recurso
central-backend-native-pull, aponte a tag da imagem para<sha anterior>-nativee faça Redeploy; depois de corrigir, volte a tag paranative-amd64, senão o próximo release re-puxa a imagem antiga. Consequência operacional: a tag imutável é ativo de produção — o registry retém as dez últimas por imagem e alvo, e nunca poda a que está no ar. - Configuração: ajuste a variável e redeploy. Um deploy que não passa no
/healthnão substitui o container anterior (rolling health-gated), então config errada derruba o deploy, não o serviço. - Migration: o rollback não desfaz o schema — o Flyway CE é forward-only. A norma expand/contract cobre isso: a release N não dropa o que N-1 ainda usa, então a imagem anterior roda sobre o schema novo. Para desfazer DDL, uma migration nova.
Configurar o PowerSync para validar tokens do central-backend¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30
O que é¶
Aponta a instância PowerSync de um cliente para o JWKS deste serviço, de modo que ela aceite os JWT emitidos no login.
Quando usar¶
- Ao provisionar a instância PowerSync de um cliente novo.
- Ao mudar o domínio/issuer do central-backend.
Pré-requisitos¶
- Auth-service no ar com JWKS publicado (
/.well-known/jwks.json). - O
audiencedo cliente emdb_auth.clientesigual ao configurado no PowerSync.
Passos¶
-
config.yamldo PowerSync — apontar o JWKS (mesmo para todos os clientes):client_auth: jwks_uri: "https://auth.xadm.biz/.well-known/jwks.json" audience: ["powersync-vantroba"] # = clientes.audience deste cliente -
audience: confirme queclientes.audience(emdb_auth) é o mesmo valor da listaaudiencedo PowerSync — o claimauddo JWT precisa casar.
Verificação¶
- No log do PowerSync, procure por
JWKS fetched successfully. - O app conecta com o JWT de login e o PowerSync aceita a conexão (log com o
user_idcorreto).
Reversão¶
Reverter a alteração no config.yaml e reiniciar a instância PowerSync. O cache de
JWKS expira sozinho (ver Rotação de chaves RSA).
Bucket hash_senhas / scope=pabast_only — legado
O desenho original sincronizava pabast_senhas para um bucket liberado pelo
scope=pabast_only (token anônimo), para o bootstrap-offline. Esse caminho
está depreciado (decisão 0006):
pabast_senhas não é mais sincronizado via PowerSync. Em instâncias novas, não
configure esse bucket.
Rotacionar a chave RSA do central-backend¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30
Coordene com o PowerSync
O serviço expõe uma chave pública por vez, e o PowerSync cacheia o JWKS (≈ 5 min). Trocar a chave invalida tokens assinados com a anterior até o PowerSync renovar o cache. Planeje, ou siga a rotação suave abaixo.
O que é¶
Substitui o par de chaves RSA que assina os JWT (ex. por suspeita de vazamento ou política de rotação).
Quando usar¶
- Suspeita de comprometimento da chave privada.
- Rotação periódica de segurança.
Pré-requisitos¶
- Acesso ao Coolify e ao cofre de segredos.
opensslna máquina local.
Passos (rotação suave, sem downtime)¶
-
Reduza a expiração dos tokens antes de rotacionar, para encurtar a janela de tokens antigos válidos:
Redeploy e aguarde a expiração natural dos tokens já emitidos.AUTH_TOKEN_EXPIRATION_MINUTES=5 -
Gere a nova chave (ver Gerar as chaves e segredos):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out auth_private_v2.pem base64 -w0 auth_private_v2.pem > auth_private_v2_b64.txt -
Atualize as variáveis no Coolify com um
kidnovo:AUTH_KEY_ID=auth-2 AUTH_PRIVATE_KEY=<conteúdo de auth_private_v2_b64.txt> -
Redeploy. O JWKS passa a expor a chave nova (
kid=auth-2). -
Aguarde o PowerSync renovar o cache (~10 min por segurança). Novos tokens já saem com a chave nova.
-
Restaure
AUTH_TOKEN_EXPIRATION_MINUTESao valor normal (1440) e redeploy.
Verificação¶
curl -sf https://auth.xadm.biz/.well-known/jwks.json | jq '.keys[0].kid'
# Esperado: "auth-2"
- Faça um login e confirme que o app conecta ao PowerSync sem erro de assinatura.
Reversão¶
Se o PowerSync passar a rejeitar tokens, reverta as variáveis (AUTH_KEY_ID e
AUTH_PRIVATE_KEY) para a chave anterior e redeploy; aguarde o cache do PowerSync
renovar. Em último caso, rollback do deploy (Coolify → Deployments).
Provedor GlitchTip — observabilidade (baseline)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
O que é¶
O GlitchTipProvider é a impl de FeatureProvider para a feature glitchtip da Central de
Apps. Quando o /xadm-setup chama POST /api/setup/provision {app_id, feature:"glitchtip"}, o
auth garante (idempotente) o projeto GlitchTip do app em bug.xadm.biz e devolve o DSN
(client-grade) no client_config — que o setup grava no app.json.
A API do GlitchTip é compatível com Sentry: criar projeto por
POST /api/0/teams/{org}/{team}/projects/, ler o DSN por
GET /api/0/projects/{org}/{slug}/keys/ (dsn.public).
Configuração (env no recurso auth no Coolify)¶
Todas environment variables do serviço (runtime), marcando só o token como Secret:
| Env | Valor | Secret |
|---|---|---|
GLITCHTIP_ADMIN_URL |
https://bug.xadm.biz (ou URL interna, se o auth não alcançar de dentro) |
não |
GLITCHTIP_ADMIN_TOKEN |
Auth Token do GlitchTip (Profile → Auth Tokens; gerar logado como owner da org) | sim |
GLITCHTIP_ORG |
slug da organização (ex. x-adm) |
não |
GLITCHTIP_TEAM |
slug do time (ex. xadm) |
não |
Ausentes → o provider recusa provisionar com erro claro (GLITCHTIP_ADMIN_URL ...), sem
quebrar o boot; o par fica em estado falha + last_error, re-tentável quando as envs existirem.
Comportamento¶
- Slug derivado do nome: o GlitchTip ignora o
slugenviado e deriva o slug do nome do projeto (ex. "Auth Service" →central-backend). Por isso o provider descobre o slug real: listaGET /api/0/projects/e casa por (org + nome) — não assumeslug == app_id. - Idempotente: acha o projeto pelo nome; existe → reusa. Não existe → cria
(
POST .../teams/{org}/{team}/projects/, complatformderivado da stack) → re-lista p/ pegar o slug → lê o DSN em.../projects/{org}/{slug}/keys/(dsn.publicdo 1º key). - Sem segredo per-app: o DSN é a chave pública (client-grade) — não vai
secret_ref. OSENTRY_AUTH_TOKEN(upload de sourcemap) é org-wide e é env de build do app, não gerido aqui. - Alerta padrão (best-effort): ao garantir o projeto, o provider também garante um alerta
padrão — notifica a cada 1 evento em 1 minuto por e-mail aos membros do projeto
(
POST .../projects/{org}/{slug}/alerts/, corpo camelCasetimespanMinutes:1, quantity:1, alertRecipients:[{recipientType:"email"}]). Idempotente (não recria se o projeto já tem algum alerta) e best-effort: falha aqui só loga (WARN) — nunca quebra a provisão do DSN, que é o entregável. Um projeto que já tinha alerta próprio é respeitado. - Falha do provedor (inacessível, resposta inesperada) →
falha+last_error, sem config parcial.
Alerta de silêncio do integrador-client¶
O próprio central emite, no seu projeto GlitchTip, um evento por episódio de silêncio de uma instalação do integrador-client — uma issue por cliente, reincidências na mesma issue (decisão 0028, runbook heartbeat do integrador-client). A reincidência só notifica se a regra de alerta do projeto for por evento — o alerta padrão acima ("1 evento em 1 minuto").
Como o provider respeita alerta próprio pré-existente, confira o projeto do central antes de ligar o heartbeat: em Alerts do projeto, a regra tem de disparar por quantidade de eventos, não só em "issue nova". Se for só "issue nova", troque pela regra por evento — senão o 2º silêncio de um cliente cai calado na issue antiga.
Verificação¶
# provisiona o glitchtip do próprio auth (dogfood) e confere o DSN na resposta
curl -s -X POST https://auth.xadm.biz/api/setup/provision \
-H "Authorization: Bearer <JWT xadm_admin>" -H "Content-Type: application/json" \
-d '{"app_id":"auth","feature":"glitchtip","grupo":"xadm","nome":"Auth"}' | jq .
# → { "client_grade": { "enabled": true, "dsn": "https://<pub>@bug.xadm.biz/<n>" } }
Re-executar o mesmo comando deve devolver o mesmo DSN (idempotente), sem criar projeto novo.
Reversão¶
deprovision não implementado (política: não destrói recurso por default). Para desligar, o admin
desliga a capacidade no par (o auth para de servir o bloco no /config); o projeto GlitchTip
permanece em bug.xadm.biz até remoção manual.
Provedor Garage — arquivos (bucket + key + presign)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-03
O provider de arquivos da Central de Apps: por app, provisiona 1 bucket (globalAlias =
app_id) + 1 access key read/write escopada, no cluster Garage (S3-compatível, Admin API v2).
O app acessa os arquivos por URLs presignadas que o auth assina (broker-signed) — a write-key
nunca sai do servidor. Porquê e alternativas: decisão 0012.
Envs no recurso auth (Coolify)¶
Lidas em runtime pelo auth (não build). Marque o token como Secret.
| Variável | Valor | Secret? |
|---|---|---|
GARAGE_ADMIN_URL |
Admin API interna do Garage (ex. http://garage:3903) ou https://arquivo-admin.xadm.biz |
não |
GARAGE_ADMIN_TOKEN |
admin token (o GARAGE_ADMIN_TOKEN/SERVICE_PASSWORD_GARAGE do cluster) |
sim ✅ |
GARAGE_S3_ENDPOINT |
endpoint S3 (ex. https://arquivo-api.xadm.biz) — usado no presign |
não |
GARAGE_S3_REGION |
região SigV4 (s3_region do garage.toml, ex. garage) |
não |
Ausentes → o provider recusa provisionar com erro claro (não quebra o boot). O cluster precisa de
GARAGE_ALLOW_WORLD_READABLE_SECRETS=true para o auth re-obter o secret na re-provisão
(GetKeyInfo?showSecretKey=true).
Provisão (idempotente)¶
Disparada pelo /xadm-setup (feature garage) → POST /api/setup/provision. O provider:
GET /v2/GetBucketInfo?globalAlias=<app_id>— acha o bucket, ouPOST /v2/CreateBucket.- Reusa a key associada ao bucket (
GetKeyInfo?showSecretKey=true) — ouPOST /v2/CreateKey+POST /v2/AllowBucketKey(read+write,owner:false). - Grava
client_config = {endpoint, bucket, region}(→app.json) e osecretAccessKeycifrado emsecret_ref. Re-rodar reusa bucket+key (sem duplicar).
Acesso a arquivos (presign broker-signed)¶
O app (com JWT de usuário do auth, claim oauth_app_id) chama:
GET /api/apps/garage/presign?app_id=<app>&op=GET|PUT&key=<objeto>
Authorization: Bearer <JWT do usuário>
→ 200 { "url": "<presigned SigV4, 10 min>", "expires_in": 600 }
- op=GET → download; op=PUT → upload. A URL vale ~10 min.
- Autz:
app_idtem que casar com ooauth_app_iddo token → 403 cross-app. Token semoauth_app_id→ 403. Sem token → 401. App sem Garage provisionado → 404.
Diagnóstico¶
- Provisão falha → a resposta do
/provisiontrazproviders[].last_error(ex.Garage GetBucketInfo HTTP 401= token inválido;Garage inacessível= URL/rede). - Presign 404 → o par (app,
garage) não estáligadano catálogo (rodar o setup). - Presign dá URL mas o objeto não abre → conferir
GARAGE_S3_ENDPOINT/GARAGE_S3_REGION(região SigV4 tem que casar com ogarage.toml) e se o cluster aceita path-style.
Acesso S3 direto (app server/web)¶
Para app server/web (tem production_url → recurso Coolify), o broker injeta a write-key no
recurso do app, além do client-grade — o app fala S3 direto (sem o round-trip de presign). Envs
que o app recebe:
| Env injetada | Origem | Secret? |
|---|---|---|
GARAGE_ENDPOINT |
client_config.endpoint |
não |
GARAGE_BUCKET |
client_config.bucket (= app_id) |
não |
GARAGE_REGION |
client_config.region (SigV4) |
não |
GARAGE_ACCESS_KEY_ID |
resource_ref.access_key_id |
não |
GARAGE_SECRET_ACCESS_KEY |
secret_ref (decifrada no ato) |
sim ✅ (mascarada por is_shown_once) |
O app configura um cliente S3 (path-style, endpoint override) com essas envs. A key é per-app,
escopada só ao bucket do app (read+write, sem owner). Rotação: re-rodar /xadm-setup (re-injeta) +
reiniciar o recurso. O secret entra só no Coolify, nunca no app.json.
Mobile/local (sem production_url) não recebe a write-key → usa o presign broker-signed
(acima), com o segredo só no auth. Presign para device, S3 direto para server — coexistem. Ver
decisão 0012.
Injeção de config no Coolify¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-01
O que é¶
Ao provisionar uma feature (POST /api/setup/provision), o auth entrega o resultado
client-grade no recurso Coolify do app-alvo, gravando cada valor como env <PROVIDER>_<KEY>.
Ex.: provisionar garage para o app auth injeta GARAGE_BUCKET no recurso auth no Coolify.
Exceção — nome canônico de SDK. A derivação <PROVIDER>_<KEY> vale quando quem lê o env é
código nosso. Quando o consumidor é um SDK de terceiro que já tem nome de env canônico, o canônico
ganha — senão o app precisaria de código só para traduzir o nome. Hoje há uma exceção, na tabela
ENVS_CANONICOS do CoolifyClient:
provider.key |
env injetado | por quê |
|---|---|---|
glitchtip.dsn |
SENTRY_DSN (não GLITCHTIP_DSN) |
GlitchTip é Sentry-compatível e os apps usam o SDK do Sentry (Java e Dart), que lê SENTRY_DSN do ambiente por convenção. Mesmo mapeamento que o Dockerfile Flutter já faz no --dart-define. |
É o mecanismo de entrega para apps servidor (Coolify): o client-grade que o app consome em
runtime vira env. Para mobile/local (sem Coolify) é no-op — o client-grade vai só no
app.json (entregue via --dart-define no build).
Spike resolvido: a API do Coolify v4 tem set-env idempotente (
PATCH /applications/{uuid}/envs/bulk, casa porkey) — então a Fase 4 é um injetor real, não o plano B de "só avisa o drift".
Como funciona (decisão Fase 4, opção A)¶
- Configurado? Sem
COOLIFY_API_URL/COOLIFY_API_TOKEN→ no-op. - É app servidor? Sem
production_urlno catálogo → no-op (mobile). - Acha o recurso por domínio (decisão 3.4b):
GET /applications→ casafqdn == production_url(compara só o host, ignora esquema e múltiplos fqdn separados por vírgula). Não achou → loga aviso e para (não injeta). - Upsert idempotente:
PATCH /applications/{uuid}/envs/bulkcom{"data":[{"key":"<PROVIDER>_<KEY>","value":"…","is_shown_once":true}]}.
Best-effort: falha na injeção não derruba a provisão (o provider já está ligada e o
client-grade já foi gravado) — só loga WARN. O env não reinicia o app sozinho: aplica no
próximo deploy (GET /deploy?uuid=…).
Configuração (env no recurso auth no Coolify)¶
| Env | Valor | Secret |
|---|---|---|
COOLIFY_API_URL |
base da API com /api/v1 (ex. http://coolify:8000/api/v1 interno, ou https://<host>/api/v1) |
não |
COOLIFY_API_TOKEN |
token do Coolify (UI: Keys & Tokens → API tokens), escopo write (+ deploy se for disparar redeploy) |
sim |
Verificação¶
Provisionar o glitchtip do próprio auth (dogfood) e conferir o env no recurso:
curl -s -X POST https://auth.xadm.biz/api/setup/provision \
-H "Authorization: Bearer <JWT xadm_admin>" -H "Content-Type: application/json" \
-d '{"app_id":"auth","feature":"glitchtip","grupo":"xadm","nome":"Auth","production_url":"https://auth.xadm.biz"}'
Depois, no painel do Coolify → recurso auth → Environment Variables: deve aparecer
SENTRY_DSN (mascarado). Nos logs do auth: Coolify: 1 env(s) injetado(s) …. Recurso não
encontrado → Coolify: recurso não encontrado p/ app=auth … (checar se o fqdn do recurso ==
production_url).
Limitações conhecidas¶
- A API v4 não seta build-vs-runtime — o env entra com o default do Coolify (build+runtime).
production_urlprecisa bater com ofqdndo recurso; domínio múltiplo/alterado quebra o match (é o trade-off da descoberta por domínio, decisão 3.4b — a alternativa era uuid explícito).- O env é inerte até o app lê-lo. Injetar
SENTRY_DSNnoauthprova a entrega, mas oauthsó reporta erro ao GlitchTip depois de instrumentado com um SDK (passo à parte). - A injeção é upsert, não substituição — casa por
key, então renomear um env (comoGLITCHTIP_DSN→SENTRY_DSN) não apaga o nome antigo: reprovisionar cria o novo e deixa o velho órfão no recurso. Remoção do órfão é manual, no painel.
Limpar as envs sem leitor no Coolify depois do release¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
O que é¶
O scripts/coolify/limpeza.py apaga, nos recursos do Coolify, as envs que o código de cada app deixou
de ler. É a metade "depois do deploy" do ciclo de vida de env
(coolify, Ciclo de vida e auditoria de env):
env nova se cadastra antes do deploy que a lê; env que sumiu se apaga depois que todo deploy está na
versão nova. O script mora neste repo porque o central-backend é o dono do CoolifyClient e do
control-plane de deploy.
Quando usar¶
A cada leva de releases, até o relatório terminar em limpeza COMPLETA. Cada app só é tocado quando o
próprio release está servindo; os demais ficam para a rodada seguinte.
Pré-requisitos¶
- O checkout da raiz multi-repo, com os clones dos repos listados no
PLANOdo script: ele rodagit fetchegit grepneles. A raiz vem da envXADM_RAIZ; sem ela, é a pasta quatro níveis acima do script (<raiz>/integracao/central-backend/scripts/coolify/). <raiz>/.envcomCOOLIFY_URLeCOOLIFY_TOKEN. A API do Coolify tem allowlist de IPv4: rode de uma máquina dentro dela. O script força IPv4.- Python 3, sem dependência externa.
Passos¶
python scripts/coolify/limpeza.py # dry-run: por app, se o gate passou e o que apagaria
python scripts/coolify/limpeza.py --aplicar # apaga só nos apps liberados
Leia o dry-run antes do --aplicar: cada linha diz o app, o gate e as chaves que sairiam.
As travas¶
-
Gate por app — nada se apaga antes de o código novo estar servindo:
versao: o/infopúblico do app serve versão maior que a baseline doPLANO;commit: stack compose (ps.*): o último deployfinisheddescende do commit da migração, conferido no clone local;- sem gate: env que nenhuma versão lê (
NIXPACKS_*com build pack Dockerfile).
A perna
-jar-pullsegue o gate da-native-pulldo mesmo app. 2.git grep -w <CHAVE>no ref liberado, fora de*.mdedocs/: chave que ainda aparece em código ou config fica, e o script avisa (AINDA LIDAS).
Backup¶
Com --aplicar, as chaves apagadas e os valores vão para scripts/coolify/backup-<data>.json. O
arquivo tem segredo: o .gitignore o exclui; apague depois de conferir. Env travada
(is_shown_once) sai com valor vazio no backup, porque a API não devolve o valor — é "travada, valor
desconhecido", nunca "vazia".
Manter o PLANO¶
O PLANO (recurso Coolify → repo, gate e chaves) é a lista da leva em curso. Uma leva nova de envs sem
leitor acrescenta a entrada com a baseline — a versão no ar antes do release que deixou de ler a chave —
e as chaves. Recurso que sumiu do Coolify é ignorado, com aviso.
O que fica fora¶
- Apagar recurso (as pernas
-jar-pulldesligadas): remover recurso no Coolify não tem volta, e nenhum script faz isso. - Cadastrar env nova antes do deploy: é a subseção
Coolify — antes do deployda### Migraçãodo CHANGELOG, que a/xadm-releasepreenche.
Configurar links contextuais e embed do Metabase na Central¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
O que é¶
O que você precisa configurar nos servidores para acender, no detalhe do app da Central:
- Links contextuais (abrir GlitchTip / abrir Metabase) — funciona só com deploy, sem infra nova.
- Embed do dashboard Metabase (iframe no painel) — exige um passo manual no Metabase (A0) + uma env.
O código dos dois lados (auth + authui) já está pronto e casado; falta configuração de servidor e deploy. Base técnica: decisão 0017.
Degradação graciosa: sem a env de embed, a Central mostra o link simples (abre o dashboard numa aba). Nada quebra — o embed apenas não aparece. Ou seja, dá pra deployar o auth antes de fazer o A0; o embed acende sozinho quando a env aparecer.
TL;DR (checklist)¶
- [ ] 1. Conferir que
GLITCHTIP_ADMIN_URLeMETABASE_URL(recursoauthno Coolify) são as URLs públicas (as que o browser abre) — os links reusam essas. - [ ] 2. (embed) No Metabase: Admin → Settings → Embedding → habilitar Static embedding e copiar a Embedding secret key.
- [ ] 3. (embed) No recurso
auth(Coolify): setarMETABASE_EMBEDDING_SECRET(secret) com essa key. Opcional:METABASE_EMBED_TTL_SECONDS(default600). - [ ] 4. Deploy do auth (build novo) e do authui/central.
- [ ] 5. (embed) Re-provisionar os apps que já existem (
/xadm-setup, featureanalytics) — o flagenable_embeddingdo dashboard só é setado no provision. - [ ] 6. Verificar (curl no catálogo + abrir o detalhe na Central).
1. Pré-requisito: URLs públicas (links contextuais)¶
Os links reusam as envs que os providers já usam para a API admin. Confirme que, no recurso
auth (Coolify → auth → Environment), elas apontam para o host público (o que o navegador abre),
não para uma URL interna de rede:
| Env | Valor esperado | Usada para |
|---|---|---|
GLITCHTIP_ADMIN_URL |
https://bug.xadm.biz |
link bug.xadm.biz/<org>/<slug> |
METABASE_URL |
https://metabase.xadm.biz |
link <metabase>/dashboard/<id> e o embed |
Se hoje elas já são as públicas (o GlitchTip/Metabase são acessados por esses hosts), não há nada a
mudar aqui — os links contextuais funcionam já no deploy. Não precisa re-provisionar para o link
(o org/slug do GlitchTip e o dashboard_id do Metabase já estão persistidos).
Se alguma dessas URLs for interna (rede docker/VPN) e diferente da pública, o link resolvido abriria um host que o browser não alcança. Nesse caso, me avise — aí trocamos por envs de URL pública dedicadas (
glitchtip.public-url/metabase.public-url).
2. Habilitar Static embedding no Metabase (A0)¶
Só necessário para o embed (o link funciona sem isto). No Metabase (metabase.xadm.biz), logado
como admin:
- Admin settings → Embedding.
- Ligar Static embedding (às vezes chamado "Embedding in other applications" / "Signed embedding"). Aceitar os termos.
- Copiar a Embedding secret key (string longa; é o segredo que assina os JWT de embed).
Sem este passo, o Metabase recusa
/embed/dashboard/<jwt>com 400 mesmo com a env setada.
3. Env de embed no recurso auth (Coolify)¶
No recurso auth (Coolify → auth → Environment Variables), adicionar:
| Env | Valor | Secret? |
|---|---|---|
METABASE_EMBEDDING_SECRET |
a Embedding secret key do passo 2 | sim ✅ |
METABASE_EMBED_TTL_SECONDS |
validade da URL assinada em segundos (default 600 = 10 min) |
não |
Marque METABASE_EMBEDDING_SECRET como secret/build-time conforme o padrão da casa
(injeção de secrets no Coolify). Salvar → redeploy do auth para as
envs entrarem.
4. Deploy¶
- auth — deploy do build novo (ver deploy no Coolify).
- authui/central — deploy do build novo (o consumo dos links/embed é do lado da Central).
5. Re-provisionar os apps (só para o embed)¶
O MetabaseProvider seta enable_embedding:true no dashboard durante o provision. Dashboards
provisionados antes desta versão não têm o flag — o embed não renderiza até ele existir.
Para cada app com analytics, rode o /xadm-setup (feature analytics) novamente — é idempotente:
não recria nada, só garante o enable_embedding (um PUT no dashboard). Alternativa manual: no
Metabase, abrir cada dashboard → Sharing → Embed → Static embedding → Publish.
Isto não é necessário para os links (item 1) — só para o embed.
6. Verificar¶
No contrato (rápido): com um token admin, olhar o links de um app:
curl -s https://auth.xadm.biz/api/admin/apps/<APP_ID> \
-H "Authorization: Bearer <JWT_XADM_ADMIN>" | jq .links
Esperado (app com GlitchTip + Metabase ligada):
{
"producao": "…", "docs": "…", "source": "…",
"glitchtip": "https://bug.xadm.biz/<org>/<slug>",
"metabase": "https://metabase.xadm.biz/dashboard/<id>",
"metabase_embed": "https://metabase.xadm.biz/embed/dashboard/<jwt>#bordered=true&titled=true"
}
glitchtip/metabasepresentes → links contextuais OK.metabase_embedpresente → embed OK (secret +enable_embeddingno lugar). Se viernullmas os outros dois estão OK: falta a env (passo 3) ou o re-provision (passo 5) — a Central cai no link (esperado, não é erro).
Na Central (real): abrir o detalhe do app. Linha glitchtip/analytics mostra "abrir";
com embed + navegador web, o dashboard aparece embutido (iframe). Sem embed → o botão de link.
Reverter / desligar o embed¶
Remover METABASE_EMBEDDING_SECRET do recurso auth e redeploy → metabase_embed volta a null e a
Central usa o link. Rotacionar o secret invalida as URLs em voo (TTL curto → impacto pequeno).
Resumo por servidor¶
| Onde | O que fazer |
|---|---|
Metabase (metabase.xadm.biz) |
habilitar Static embedding, copiar a embedding secret key (passo 2) |
Coolify → recurso auth |
conferir GLITCHTIP_ADMIN_URL/METABASE_URL públicas (1); setar METABASE_EMBEDDING_SECRET (+METABASE_EMBED_TTL_SECONDS) (3); redeploy (4) |
Coolify → recurso authui/central |
deploy do build novo (4) |
/xadm-setup (dev/CI) |
re-provisionar analytics dos apps existentes p/ o enable_embedding (5) |
Rollout canário do auth native — JVM + native lado-a-lado¶
Documento obsoleto
Status: Obsoleto · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14
Obsoleto
O corte terminou: o central-backend é native-only e o pipeline.yml não builda mais o jar
(decisão 0030). A página fica como registro do
procedimento; rollback hoje é o redeploy da tag imutável <sha>-native (Deploy).
Pré-requisito: o e2e native (
etc/tests/native/e2e-central-backend) verde e a decisão 0018. Este runbook é o corte em produção.
O auth é o SPOF do ecossistema — não se corta 100% pra native num passo. A estratégia: subir a
imagem native ao lado do JVM atual, com o Traefik do Coolify dividindo o tráfego por peso
(weighted round-robin); validar o native em produção com uma fração do tráfego; quando 100% ok,
desligar o JVM e seguir native-only (rollbacks futuros passam a ser o rollback normal de
container do Coolify).
⚠️ Validar na tua VM antes do corte real. Os nomes de serviço Traefik e o suporte a weighted entre dois recursos Coolify dependem da versão do Coolify/Traefik. Rode os passos de inspeção (§1) e teste os pesos com um recurso de baixo risco antes de aplicar no
auth. Coolify não tem UI de canário — isto é config dinâmica do Traefik, aplicada por arquivo.
0. Topologia¶
- Recurso A —
auth(JVM): o que já existe, roteandoauth.xadm.biz. Build Pack = Dockerfile (o Coolify builda o repo). - Recurso B —
auth-native: novo recurso Coolify, mesma env do A (chaves, DB, cipher, firebase), Build Pack = Docker Image apontando pra imagem native pronta no registry (§Build da imagem). - Traefik: em vez de cada recurso dono do router de
auth.xadm.biz(conflito), um router no arquivo dinâmico aponta pra um serviço weighted que balanceia entre A e B.
Build da imagem native (por que difere do JVM)¶
O native-image come ~6 GB de RAM + minutos de CPU — buildar no Coolify trava a VM de produção
(o coolify-prod-vm-freeze que motivou o e2e native off-host). Então, ao contrário do JVM (que o
Coolify builda do repo), a imagem native é buildada fora e o Coolify só puxa a imagem pronta.
O método é o Dockerfile.native hand-written na raiz do repo (builder native-image-community:25i2-ol8
→ ./gradlew nativeCompile -PnativeCI → runtime ubuntu:24.04), buildado e publicado pela CI — não
mais ./gradlew dockerBuildNative (plugin/wolfi). Quem builda e empurra é o workflow do GitHub
.github/workflows/build-native.yml (dispara em tags v*, publish-only, não faz deploy):
# a CI (GitHub) builda o Dockerfile.native e publica no registry:
# fonte.xadm.biz/xadm/central-backend:native-amd64 (+ :<sha>, e espelho ghcr.io)
# Coolify (Recurso B): Build Pack = Docker Image = fonte.xadm.biz/xadm/central-backend:native-amd64
#
# Para reproduzir/testar a MESMA imagem localmente (onde tiver Docker):
docker build -f Dockerfile.native -t central-backend:native-test .
Roda no runner GitHub (native-image é pesado, ~6 GB de builder; o runner hospedado aguenta, o
Forgejo self-hosted não — o .forgejo/workflows/ci.yml segue como gate JVM). O Recurso B só troca
qual tag puxa; o fluxo de deploy não muda.
1. Inspecionar os serviços Traefik (root da VM do Coolify)¶
# nomes dos serviços/routers que o Coolify gerou por container
docker exec coolify-proxy traefik version
ls -la /data/coolify/proxy/dynamic/
# lista os serviços conhecidos pelo Traefik (API interna, se habilitada)
docker exec coolify-proxy wget -qO- http://localhost:8080/api/http/services 2>/dev/null | tr ',' '\n' | grep -i auth
# ou pelos labels dos containers
docker inspect $(docker ps -q --filter name=auth) --format '{{.Name}} {{json .Config.Labels}}' | grep -i traefik
Anote os nomes de serviço dos dois containers (algo como auth-<id> e auth-native-<id>, com
sufixo @docker).
2. Definir o serviço weighted + router (arquivo dinâmico)¶
Crie /data/coolify/proxy/dynamic/auth-canary.yml (ajuste os name: aos anotados no §1):
http:
routers:
auth-canary:
rule: "Host(`auth.xadm.biz`)"
entryPoints: ["https"]
tls:
certResolver: letsencrypt # o mesmo resolver que os outros recursos usam
service: auth-canary
services:
auth-canary:
weighted:
services:
- name: "auth-<id>@docker" # Recurso A (JVM)
weight: 90
- name: "auth-native-<id>@docker" # Recurso B (native)
weight: 10
Importante: desabilite o router de domínio que o Coolify cria automaticamente em CADA recurso (senão dois routers disputam
auth.xadm.biz). No Coolify, em cada recurso, use um domínio interno/placeholder (ex.:auth-a.internal,auth-b.internal) para o Traefik ainda criar o serviço de cada um, mas o router público deauth.xadm.bizser só o do arquivo acima. Confirme que só um router casa o Host:docker exec coolify-proxy wget -qO- http://localhost:8080/api/http/routers | tr ',' '\n' | grep auth.xadm.biz.
O Traefik recarrega o arquivo dinâmico sozinho (watch). Sem restart.
3. Escalar o peso (validação progressiva)¶
Comece 90/10, observe, suba. Editar o weight: e salvar basta (hot-reload):
# ex.: 50/50 — editar auth-canary.yml e trocar os weights, depois:
docker exec coolify-proxy wget -qO- http://localhost:8080/api/http/services | tr ',' '\n' | grep -A2 auth-canary
A cada degrau (10 → 50 → 100%), monitorar (§4). Sinal ruim em qualquer degrau → rollback (§5).
4. Monitoração (o que olhar durante o canário)¶
- Emissão/validação de JWT: logs do
auth-native—Login pAbast OK, ausência de500/INTERNAL_SERVER_ERRORem/api/auth/**,/.well-known/jwks.jsone/api/apps/**. O PowerSync cacheia o JWKS — a chave é a mesma (mesmokid=auth-1), então não há invalidação. - GlitchTip (bug.xadm.biz): picos de erro do recurso
auth-native. - RSS/boot:
docker statsdo container native (esperado ~43 MiB, boot ~1 s). - Comparar A vs B: as respostas dos dois recursos devem ser idênticas (mesmo contrato).
docker logs -f $(docker ps -q --filter name=auth-native) | grep -iE "login|error|exception|500"
5. Rollback imediato¶
Voltar o peso do native a 0 (ou remover o serviço B do weighted) — hot-reload, sem deploy:
# editar auth-canary.yml: weight do auth-native -> 0 (ou weight do JVM -> 100 e remover B)
# Traefik recarrega sozinho; todo o tráfego volta pro JVM em segundos.
Se o arquivo dinâmico em si for o problema, rm /data/coolify/proxy/dynamic/auth-canary.yml e
reabilitar o router de domínio no Recurso A (JVM) pela UI do Coolify.
6. Collapse (native-only)¶
Native em 100% e estável por uma janela combinada:
- Reabilitar
auth.xadm.bizdireto no Recurso B (native) pela UI do Coolify (router próprio). - Remover
/data/coolify/proxy/dynamic/auth-canary.yml. - Desligar/remover o Recurso A (JVM).
- A partir daqui, rollback = o rollback normal de container/deploy do Coolify no Recurso B.
Notas¶
- Build da imagem native: off-host + push pro registry do Forgejo (ver §Build da imagem). O Coolify não compila native no deploy (build-spike travaria a VM).
- Env idêntica: o Recurso B precisa das MESMAS
AUTH_PRIVATE_KEY/AUTH_PUBLIC_KEY,AUTH_CIPHER_KEY,DATASOURCES_*,AUTH_FIREBASE_PROJECT_ID.AUTH_FIREBASE_JWKS_URLfica no default (Google) — o override só existe pro e2e.
Provisionar o monitor de host/apps do Coolify¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Habilita os endpoints
GET /api/admin/monitor/hosteGET /api/admin/monitor/appsdocentral-backend(Fase 1, read-only — decisão 0019). Três peças de infra: o proxy do socket (serviço Coolify à parte), mounts do host e env no recurso do central. Tudo leitura — nenhuma ação destrutiva.⚠️ Segurança: o socket do Docker é equivalente a root no host. O central nunca monta o socket cru — fala só com o proxy, que libera uma allowlist mínima de rotas GET. Um bug com o socket cru = comprometimento total de produção.
Estado em 2026-09-11: o proxy está no ar e verificado em produção (§1). Falta ligar o
central a ele — mounts + env + redeploy do central-backend-native-pull (§4), que depende do release do
central com o /system/df?type=build-cache (commit beb542b).
1. Proxy do socket — wollomatic/socket-proxy (allowlist por rota)¶
Por que não o tecnativa/docker-socket-proxy: lá a flag CONTAINERS=1 é regra por prefixo
(^(/v[\d\.]+)?/containers) e libera todo GET sob /containers — /containers/{id}/json expõe o
env de todos os containers (inclusive AUTH_PRIVATE_KEY), além de archive, export e logs. O
wollomatic/socket-proxy aplica regex ancorada por rota: libera só as 3 rotas que o monitor lê.
O serviço em produção:
| Item | Valor |
|---|---|
| Serviço Coolify | monitor-docker-proxy (uuid uuatsojwo5fvedw7qw5sqm1q) |
| Projeto / ambiente | 000-compartilhado / production |
| Rede | "Connect To Predefined Network" ligado (rede coolify); sem porta publicada |
| Host estável p/ o central | http://docker-socket-proxy-uuatsojwo5fvedw7qw5sqm1q:2375 |
| Imagem | ghcr.io/wollomatic/socket-proxy:1.13.1@sha256:e78e68ba3ea4a6bf8ee47865ac1d1f98d525c3660df948554e766283c40d3f4b (digest do índice multi-arch) |
# docker-compose do serviço monitor-docker-proxy (Coolify)
services:
docker-socket-proxy:
image: ghcr.io/wollomatic/socket-proxy:1.13.1@sha256:e78e68ba3ea4a6bf8ee47865ac1d1f98d525c3660df948554e766283c40d3f4b
user: "65534:987" # nobody : GID do docker.sock no bi-ubuntu
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 32m
mem_reservation: 16m
cpus: 0.25
command:
- "-listenip=0.0.0.0"
- "-proxyport=2375"
- "-allowfrom=10.0.1.0/24,fde3:9396:7e00::/64"
- "-allowGET=/(v[0-9.]+/)?(containers/json|containers/[0-9a-f]{64}/stats|system/df)"
- "-allowhealthcheck"
healthcheck:
test: ["CMD", "./healthcheck"]
volumes:
# o :ro não restringe a API (é um socket) — quem restringe é o -allowGET
- /var/run/docker.sock:/var/run/docker.sock:ro
# NÃO publique porta — só a rede interna do Coolify
- Qualquer método ≠ GET é recusado (405): não há
-allowPOST/-allowDELETEetc. - Query string não entra no casamento da rota:
?stream=falsee?type=build-cachepassam. - GID
987é o dono do/var/run/docker.socknobi-ubuntu. Em outro host (ou após reinstalar o Docker), confira comstat -c %g /var/run/docker.socke ajuste ouser:.
⚠️ Pegadinha —
allowfromprecisa da sub-rede IPv6. A redecoolifyé dual-stack e o central chega ao proxy por IPv6. Com-allowfromsó na sub-rede IPv4 (10.0.1.0/24), tudo dava403 forbidden IP. Por isso as duas:10.0.1.0/24,fde3:9396:7e00::/64.
Verificado em 2026-09-11 (de dentro do container do central, na rede coolify):
| Requisição | Esperado |
|---|---|
GET /containers/json |
200 |
GET /containers/<id>/stats?stream=false |
200 |
GET /system/df?type=build-cache |
200 |
GET /containers/<id>/json, /containers/<id>/archive, /containers/<id>/logs, /containers/<id>/export |
403 |
GET /info, /system/info, /images/json |
403 |
path traversal GET /containers/json/../<id>/json |
403 |
versão prefixada GET /v1.55/containers/<id>/json |
403 |
POST /containers/create |
405 |
O log do proxy registra cada recusa com reason="path not allowed" ou reason="method not allowed".
2. Mounts do host (superfície mínima)¶
No recurso central-backend-native-pull (Coolify → Persistent Storage → Directory Mount):
| Host | No container | Para quê |
|---|---|---|
/proc |
/host/proc |
MemTotal/MemAvailable/SwapTotal/SwapFree (mem e swap) |
/srv/xadm-probe |
/host/probe |
statvfs do filesystem de / (disco % cheio) |
/srv/xadm-probeé um diretório vazio dedicado (0555 root:root, já criado no host) — não expõe nada; substitui o/etc/os-releasedo desenho original. Ostatvfsreporta o filesystem que contém o probe: ele tem de estar no filesystem de/(confira comfindmnt -T /srv/xadm-probe).- Não monte
/inteiro: o par acima dá as métricas com exposição mínima (/procé virtual). - O Directory Mount é bind rw — a API do Coolify não tem mount read-only. A pré-condição é o container
não-root: o
Dockerfile.nativeroda comoUSER app(uid 1001), e o monitor só lê. Nunca monte o/procdo host num container root: o bind não herda o read-only que o Docker aplica a/proc/syse/proc/sysrq-trigger(coolify, Criar uma aplicação).
3. Env vars (recurso central-backend-native-pull)¶
| Variável | Valor | Default |
|---|---|---|
DOCKER_PROXY_URL |
http://docker-socket-proxy-uuatsojwo5fvedw7qw5sqm1q:2375 |
vazio → fontes docker/docker_df viram erro |
HOST_MOUNT_PATH |
ponto do mount do host | /host (não precisa setar) |
Sem DOCKER_PROXY_URL as fontes docker/docker_df degradam (200 parcial); sem o mount /host/proc,
degradam proc; sem o /host/probe, degrada disco. Cada fonte falha isolada, com motivo.
4. Ligar no central (pendente)¶
Pré-requisito: release do central com o commit beb542b (/system/df?type=build-cache — sem o
type o daemon percorre todos os volumes do host, ~15 GB, a cada chamada, e o timeout estoura).
- No
central-backend-native-pull: os dois Directory Mounts do §2 e a envDOCKER_PROXY_URLdo §3. - Redeploy.
- Verifique (§5):
/monitor/hostcomfontes.docker_df:"ok"e/monitor/appscomfontes.docker:"ok". - Meça o tempo do
/monitor/apps— ele chama/containers/{id}/statssequencialmente, um por container running; registre o número para decidir se precisa paralelizar.
Rollback: tire a env e os mounts do central-backend-native-pull e redeploy (as fontes voltam a
degradar, 200 parcial). Para remover o proxy: pare/apague o serviço monitor-docker-proxy.
5. Verificar¶
Com um JWT admin (xadm_admin) ou o shared secret do integrador:
curl -s -H "Authorization: Bearer <ADMIN_JWT>" https://central-backend.xadm.biz/api/admin/monitor/host | jq '.fontes'
curl -s -H "Authorization: Bearer <ADMIN_JWT>" https://central-backend.xadm.biz/api/admin/monitor/apps | jq '.fontes'
curl -s -H "Authorization: Bearer <ADMIN_JWT>" https://central-backend.xadm.biz/api/admin/monitor/apps | jq '.apps[] | select(.limits_memory=="0") | .nome'
O último lista os apps sem limite de memória (o preditor do freeze). Cada resposta é 200; a
fonte que estiver fora aparece em .fontes/.erros com o motivo, sem derrubar o resto.
Notas¶
- Read-only, sob demanda: nenhum daemon, nenhuma ação destrutiva. Fase 2 (limpeza/restart com whitelist + confirmação + auditoria) é desenho à parte — e exigiria rever a allowlist do proxy.
- O monitor não estressa a VM: timeout curto (~3 s) por chamada ao socket, só containers running; o proxy tem teto de 32 MB / 0,25 CPU.
- CORS: os endpoints herdam o
AdminApiCorsFilter(origincentral.xadm.bizliberado) por estarem sob/api/admin/**.
Runbook — semear o catálogo de papéis de um app (app_roles)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-04
O catálogo de papéis por app é data-driven: a migration V12 cria a tabela app_roles vazia
(migration é schema, não dado). Cada app precisa ter os papéis cadastrados antes do 1º approve
daquele app — senão o approve responde 422 role_not_in_catalog.
Pré-condição: o app precisa existir no catálogo apps¶
app_roles.app_id tem FK → apps(app_id). A linha apps nasce da catalogação da Central de Apps
(POST /api/setup/provision a partir do app.json, ou backfill). Confirme:
SELECT app_id FROM apps WHERE app_id = 'bi-comercial';
Se não existir, catalogue o app primeiro (fluxo /xadm-setup / broker) — não insira papéis antes.
Semear pelo que o app declara (caminho preferido)¶
Quem sabe quais papéis existem é o app — é o código dele que faz o gate. Ele publica a lista em
GET <base>/roles-catalog.json e o central ingere
(decisão 0025):
curl -sS -X POST "https://central-backend.xadm.biz/api/admin/apps/bi-comercial/roles/sync" \
-H "Authorization: Bearer $XADM_JWT" -H "Content-Type: application/json" -d '{}'
# {"app_id":"bi-comercial","base_url":"https://bi.sulplata.xadm.biz",
# "declarados":4,"criados":4,"atualizados":0,"roles":["ADMIN","COMERCIAL","DIRETORIA","LUBRIFICANTE"]}
- Idempotente: rodar de novo devolve
criados:0. Rode sempre que o app ganhar um papel novo — é o passo que fecha o drift entre o gate do app e o picker do central. - Não deleta. Papel que sumiu da declaração fica no catálogo (apagá-lo órfãozaria os grants que já
o referenciam). Remoção é o
DELETEmanual abaixo, depois de tratar os grants. - Sem
production_urlalcançável (e2e local), passe-d '{"base_url":"http://localhost:8101"}'. 502 roles_catalog_unreachable→ o app não respondeu ou devolveu JSON inválido;422→ obase_urlnão é http(s) ou o app declarou um papel maior que a coluna.
Semear papel a papel (saída manual)¶
Quando o app ainda não serve o roles-catalog.json, ou para corrigir um caso pontual.
Com um JWT xadm (xadm_admin=true):
for r in ADMIN:Administrador DIRETORIA:Diretoria COMERCIAL:Comercial LUBRIFICANTE:Lubrificante; do
role="${r%%:*}"; label="${r##*:}"
curl -sS -X POST "https://central-backend.xadm.biz/api/admin/apps/bi-comercial/roles" \
-H "Authorization: Bearer $XADM_JWT" -H "Content-Type: application/json" \
-d "{\"role\":\"$role\",\"label\":\"$label\"}"
done
roleé gravado em UPPERCASE (machine-code; vai verbatim no claimroledo JWT e é o valor que a sync rule do PowerSync compara).409 role_exists→ o papel já existe (idempotente na prática; ignore).- Este é o caminho que drifta: o app ganha um papel, ninguém repete o
POSTaqui, e o papel não aparece no picker — sem erro nenhum. Prefira osyncacima.
Conferir: GET /api/admin/apps/{app}/roles (aqui {app} = bi-comercial) ou
SELECT role, label FROM app_roles WHERE app_id = 'bi-comercial' ORDER BY role;
Papéis do bi-comercial (referência)¶
| role | label | significado |
|---|---|---|
ADMIN |
Administrador | vê tudo + administra usuários do app |
DIRETORIA |
Diretoria | vê tudo, não administra usuários |
COMERCIAL |
Comercial | operacional (recorte comercial na sync rule) |
LUBRIFICANTE |
Lubrificante | operacional (só lubrificante na sync rule) |
pAbast intocado. Esta feature é aditiva: usuários que logam via pAbast seguem inalterados; o catálogo/aprovação só governa o acesso social (
oauth_firebase).
Heartbeat do integrador-client e alerta de silêncio¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
O que é¶
Cada instalação do integrador-client (jar on-premise, uma por cliente) manda um heartbeat no
startup e a cada 60 min ao integrador-server do próprio cliente, que carimba o cliente_id e o
repassa ao central em POST /api/integrador/heartbeat. O central guarda o estado atual da instalação
(integrador_instancias, uma linha por cliente), e o SilencioJob alerta no GlitchTip do central
quando uma instalação passa de 6 horas úteis sem sinal (07:00–22:00, seg–sex, sem feriado
nacional). O porquê está na decisão 0028; o
contrato, no api-rest.
A aba Deploy do central-ui mostra as instalações (cliente, versão, fluxos, último heartbeat, situação) e tem o botão Parar de acompanhar.
Configuração¶
| Onde | Env | Valor |
|---|---|---|
| central-backend — os dois recursos (jar e native) | CENTRAL_API_TOKEN |
segredo gerado (openssl rand -hex 32); Secret no Coolify |
| integrador-server de cada cliente com integrador-client | CENTRAL_API_TOKEN |
o mesmo valor do central |
| integrador-server de cada cliente com integrador-client | CLIENTE_ID |
o clientes.cliente_id do cliente (ex. maxsul) — nunca o nome de exibição |
Sem CENTRAL_API_TOKEN o central não sobe em prod (IntegradorTokenGuard; o rolling deploy
mantém o container anterior no ar). O cliente_id tem de existir em clientes — senão o central
responde 422 e loga WARN heartbeat recusado: cliente_id … não existe em clientes. A configuração do
lado do integrador-server está no
deploy do integrador-server.
Verificação¶
# lista as instalações (JWT xadm_admin do central-ui)
curl -s https://central-backend.xadm.biz/api/admin/integradores \
-H "Authorization: Bearer <JWT xadm_admin>" | jq .
A instalação aparece sozinha no 1º startup do integrador-client com heartbeat. No log do central, o
1º heartbeat de um cliente sai como INFO integrador-client <cliente> registrado (versão …, fluxos …).
Como ler o alerta¶
- Uma issue por cliente no projeto do central no GlitchTip, com a tag
cliente=<cliente_id>e a mensagemintegrador-client <cliente> sem heartbeat há <X,Y>h úteis (último: <data/hora local>, versão <v>, fluxos <f>). O mesmo texto sai emWARNno log do central. - Um evento por episódio de silêncio. O próximo heartbeat encerra o episódio (log
INFO integrador-client <cliente> voltou (silêncio desde …)); o silêncio seguinte manda um evento novo na mesma issue. - Causas prováveis: máquina do cliente desligada, jar parado ou travado, Java trocado,
.propertiesquebrado, ou o integrador-server do cliente fora do ar (aí o write-back também parou).
Resolver × ignorar a issue. Resolvida, ela reabre no episódio seguinte (e notifica). Ignorada, fica muda para sempre — escolha do operador, só para instalação que vai continuar parada.
A notificação depende da regra do projeto do central ser por evento — ver provedor GlitchTip.
Parar de acompanhar¶
Instalação desativada de propósito (cliente saiu, máquina trocada de vez): botão Parar de
acompanhar na linha da aba Deploy do central-ui (só aparece em silencioso e cliente_inativo).
Com a UI fora:
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
https://central-backend.xadm.biz/api/admin/integradores/<cliente_id> \
-H "Authorization: Bearer <JWT xadm_admin>"
# → 204 (com ou sem a linha — é idempotente)
Apagar não desliga: se o jar ainda roda, o próximo heartbeat (até 1h) recria a linha. Para
desligar de verdade, heartbeat.intervaloMinutos=0 no .properties do client.
Cliente desativado no central (clientes.active = false) não precisa disso: a linha aparece como
cliente_inativo e o job não alerta.
Problemas comuns¶
| Sintoma | Causa | Ação |
|---|---|---|
central não sobe após deploy; log CENTRAL_API_TOKEN vazio em prod |
env ausente no recurso | cadastrar nos dois recursos do central |
WARN heartbeat recusado: cliente_id X não existe em clientes |
CLIENTE_ID errado no integrador-server, ou cliente não cadastrado |
corrigir o CLIENTE_ID (chave minúscula) ou cadastrar o cliente |
instalação nunca aparece; integrador-server com ERROR heartbeat_recusado |
CENTRAL_API_TOKEN diferente nos dois lados (401 no central) |
igualar o valor |
linha silencioso que não volta |
jar parado na máquina do cliente | ver o log do integrador-client na máquina; se desativado de propósito, parar de acompanhar |
Dev / API
Guia do código¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14
Onde fica o quê em br.com.xadm.integracao.central. A organização é package-by-feature
(padrão da casa — engenharia/stack.md): cada feature reúne seu Controller, Service,
Repository e entidades; o transversal vive em comum. O porquê das escolhas está nas
Decisões. A referência por classe
(Javadoc) é gerada pelo CI de docs em dev/api/.
Mapa de pacotes (feature-first)¶
A → B na coluna de classes: o controller A delega ao service B.
| Pacote | Papel | Classes principais |
|---|---|---|
comum |
Transversal (nenhuma feature depende ciclicamente) | RateLimiter, config/{CentralSettings,CorsOrigins,ClockFactory}, crypto/AesCipher, email/{EmailSender,ResendEmailSender,SmtpEmailSender}, error/ApiException, jwt/{JwtService,JwtValidator}, tenant/{Cliente,ClienteRepository} |
login |
Autenticação de usuário (o core) | AuthController → AuthLoginService, PabastPasswordHasher, FirebaseTokenValidatorService, PabastSenha(+repo), AppUserAccess(+repo — acesso unificado pAbast+oauth), NewAccessRequestNotifier |
admin |
Superfície administrativa | AdminController → ClienteAdminService + PabastUsuarioAdminService, OauthFirebaseAdminController → OauthFirebaseAdminService, AdminAuthFilter, AdminApiCorsFilter |
centralapps |
Central de Apps (broker build-time) + auto-governança do cliente | SetupController → ProvisioningService, AppCatalogController → AppCatalogService + RolesCatalogSyncService, AppSelfServiceController → UsuariosDoAppService, GarageBrokerController → GaragePresignService, SetupAuthFilter, AppUserAuthFilter, AppApiCorsFilter, RolesCatalogClient, FeatureProvider, App/AppRole/AppProviderResource(+repos) |
coolify |
Client da API do Coolify (integração externa; slice compartilhado) | CoolifyClient (injectEnvs/listApplications) |
deploy |
Control-plane de deploy da frota (POST /api/ci/deploy) e painel da frota |
DeployController → DeployService + DeployStatusService, DeployFleetController → DeployFleetService, DeployReconcileService, DeployAuthFilter/DeployTokenGuard, DeployTarget/DeployAudit(+repos) |
monitoramento |
Monitor de host/apps do Coolify (Fase 1, read-only) | MonitoramentoController → MonitoramentoService, DockerStatsClient, HostMetricsReader |
integradores |
Heartbeat do integrador-client: recebimento M2M, job de silêncio e painel admin (0028) | HeartbeatController e IntegradoresAdminController → IntegradorInstanciaService, IntegradorAuthFilter/IntegradorTokenGuard, IntegradorInstancia(+repo), SilencioJob, AlertaSilencio/SentryAlertaSilencio, HorasUteis/FeriadosNacionais |
diagnostico |
Smoke-tests rodáveis em produção (/test; o do GlitchTip exige xadm_admin) |
TestController |
docs |
Poke de sync das docs (M2M do CI, sob o DeployAuthFilter) |
DocsController, DocsClient |
Deps entre features são acíclicas (ex.: admin→login/centralapps, centralapps→login/coolify,
monitoramento→coolify; nenhum volta; comum não depende de feature). integradores e diagnostico
dependem só de comum: as rotas deles sob credencial admin (/api/admin/integradores, /test/glitchtip)
são cobertas pelo AdminAuthFilter por path, sem importar admin. O CoolifyClient mora no
slice coolify (não em centralapps) desde que monitoramento também passou a consumi-lo
(decisão 0019). Ver também
decisão 0010.
Dois efeitos práticos da regra "sem ciclos", que já morderam: o helper corsHeaders mora em
comum/config/CorsOrigins, e não no filtro de admin, porque o filtro gêmeo de centralapps
precisa dele; e o AdminContato do corpo do 403 é um record local ao pacote login, em vez de
reusar o de centralapps.
Camadas dentro da feature¶
- Controller (
@Controller) é a borda HTTP: rota, Bean Validation do corpo, leitura dos atributos que o filtro pôs no request (o escopo(cliente, app)do claim, o gate de papel) e o mapeamento para os DTOs HTTP —record @Serdeableaninhados no próprio controller. Não injeta repository nemDataSource; injeta um ou dois services. - Service (
@Singleton, sufixoService) é a regra: consulta e grava pelos repositories, lançaApiException(status, code, msg)(problem+json, decisão 0016) e devolve entidade ou record próprio —PabastUsuarioAdminService.UsuarioComAcesso,AppCatalogService.AppDetalhe,UsuariosDoAppService.Usuarios—, nunca o DTO do controller. Nenhum service abre transação (@Transactional): cada chamada de repository commita sozinha. - Repository (
@JdbcRepository) é chamado por service, job (SilencioJob) ou notifier (NewAccessRequestNotifier) — nunca por controller. - Entidade (
@MappedEntity/@Embeddable) é dado: não conhece settings, container de beans, HTTP nem JDBC cru.
Fronteiras travadas¶
O ArchitectureTest (ArchUnit, roda no check) cobra as fronteiras de
Java/Micronaut, Guarda de fronteiras de arquitetura.
As cinco primeiras regras vêm da fábrica RegrasArquitetura da xadm-comum-teste; a de entidade é deste app:
| Regra | O que trava |
|---|---|
IMPORT_NAO_VAZIO |
import de zero classe falha alto, em vez de deixar as outras regras passarem vácuas |
SEM_CICLOS_ENTRE_FEATURES |
ciclo entre os pacotes de topo |
BASE_COMUM_NAO_DEPENDE_DE_FEATURES |
comum dependendo de qualquer feature |
CONTROLLER_NAO_ACESSA_REPOSITORY |
@Controller dependendo de repository (GenericRepository) ou de javax.sql |
DOMINIO_NAO_DEPENDE_DE_CONTROLLER |
qualquer classe fora do controller dependendo dele ou de um DTO aninhado nele (o código que o Micronaut gera — bean definitions, introspecções, serdes — fica fora) |
CONFIG_E_INFRA_NAO_VAZAM_PARA_O_DOMINIO |
entidade dependendo de comum.config, io.micronaut.context, io.micronaut.http ou javax.sql |
Controller e repository moram no mesmo pacote, então as regras de camada são por tipo (anotação,
herança), não por pacote. A guarda só morde com ArchUnit ≥ 1.4.1: abaixo disso o ASM não lê
bytecode do JDK 25, descarta as classes em silêncio e as regras passam vácuas, verdes — é o que o
IMPORT_NAO_VAZIO pega.
Fluxo de uma requisição¶
AuthController → limita (RateLimiter) → AuthLoginService, que consulta os repositories, confere a
identidade (PabastPasswordHasher ou FirebaseTokenValidatorService) e emite o token (JwtService).
Em /api/admin/** e no /test/glitchtip, o AdminAuthFilter roda antes do controller,
validando o JWT interno com JwtValidator (decisão 0007).
Invariantes que o código mantém¶
alg=RS256no JWKS — oJwtServiceadiciona à mão; onimbus-jose-jwtnão inclui por padrão e o PowerSync recusa sem ele (decisão 0002).PabastSenhaRepositorysó por par(cliente_id, id)— os finders herdadosfindById/update/deleteById(poridsozinho) não devem ser usados: cruzariam clientes (decisão 0008).- Algoritmo pAbast imutável —
PabastPasswordHasheré porta literal do integrador/Flutter; mudar a lógica quebra os hashes em produção. - Senha em claro nunca sai — as respostas do admin trazem os hashes pAbast
(
senha/senha_compl), que a UI admin lista; a senha em claro só entra, e oPabastUsuarioAdminServicea hasheia antes de gravar. - Migrations Flyway congeladas — as
V*já aplicadas não se editam; criar nova.
Como rodar localmente¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14
Build e testes estão em Como buildar e testar; a organização do código, no guia do código.
Pré-requisitos¶
- JDK 25 no
JAVA_HOME. O pluginio.micronaut.application5 só configura numa JVM 25, então o toolchain do build não basta: o Gradle precisa rodar nela. A versão é a dodocs/app.json(toolchain.java), a mesma da CI — ver a decisão 0009. - Docker rodando: o
runsobe o Postgres de dev, e os testes usam Testcontainers. - Build pelo wrapper (
./gradlew; no Windows,.\gradlew.bat) — nunca Maven.
Subir o app¶
./gradlew genDevKeys # uma vez: par RSA de dev em dev-keys/ (gitignored)
./gradlew run # sobe o Postgres 18 e o app em http://localhost:8080
O run chama antes a task subirInfraDev, que sobe o Postgres do docker-compose.dev.yml na porta
5433, com as credenciais que o application.yml usa por padrão, e espera o banco ficar saudável. Para
parar o banco: ./gradlew pararInfraDev.
Quem já tem um Postgres próprio exporta DATASOURCES_DEFAULT_URL (e DB_USER/DB_PASSWORD): a subida do
Docker é pulada. As demais variáveis de ambiente estão na tabela do README do repo; para o login Firebase
local, defina CENTRAL_FIREBASE_PROJECT_ID.
Chaves e tokens de dev¶
| Task | O que gera |
|---|---|
genDevKeys |
par RSA de dev em dev-keys/, usado pelo profile dev |
genDevAdminToken |
JWT xadm_admin de 365 dias assinado com a chave de dev, para o auth.token do integrador.properties |
genAuthTestKeys |
par RSA dos testes de integração; o test já depende dela |
./gradlew tasks --group development lista as tasks de dev.
Rodar a imagem native¶
docker build -f Dockerfile.native -t central-backend:native-test .
É o mesmo Dockerfile.native que a CI builda e publica. Para o loop rápido só do binário, sem imagem:
./gradlew nativeCompile -PnativeQuick --no-configuration-cache (GraalVM 25 no JAVA_HOME; o
-PnativeQuick troca o -Os de produção pelo -Ob, que compila mais rápido). O gate de adoção native é
o e2e do workspace em etc/tests/native/e2e-central/ (scripts/run-native.ps1 -Rebuild), que sobe a
imagem e valida login → JWT → assinatura, Firebase, presign S3 e o locale pt-BR do alerta de silêncio — ver a decisão 0018.
Testar uma lib da casa antes do release¶
O build.gradle.kts traz o mavenLocal() depois do registro e só para o grupo br.com.xadm, como manda
Bibliotecas da casa. A versão publicada sempre sai do
registro; o ~/.m2 só preenche o número que ainda não saiu. Para testar o app contra uma mudança da
xadm-commons, a lib sobe o número do módulo e publica local (publishToMavenLocal), e o app declara esse
número novo — republicar local um número que já está no registro não chega ao app.
./gradlew check # a versão nova vem do ~/.m2
./gradlew --offline check # com o registro fora do ar: o Gradle pula o registro e lê o ~/.m2 e o cache
A CI só fica verde depois que a versão sai no registro: o runner do pipeline.yml não tem ~/.m2, e a
/xadm-release recusa dependência da casa que não está no registro.
Como buildar e testar¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14
Pré-requisitos (JDK 25 no JAVA_HOME, Docker) e como subir o app: Como rodar.
Testes¶
./gradlew test # todos; exige Docker (Testcontainers)
./gradlew test --tests "br.com.xadm.integracao.central.login.PabastLoginControllerTest"
./gradlew check # o gate da CI: checkstyle, testes, ArchUnit e cobertura
Postgres de teste¶
Um container só (postgres:18-alpine, com os dados em tmpfs) serve a JVM de teste inteira: é o
singleton PostgresTestResource da xadm-comum-teste, e o Flyway migra o schema uma vez. A primeira
classe leva uns 20 s, que é a subida do container; as seguintes reusam o banco. Não há como apontar os
testes para um banco externo.
Teste de integração estende o IntegracaoComPostgres da xadm-comum-teste (@TestInstance(PER_CLASS) e
PostgresTestPropertyProvider). Antes de cada teste ela esvazia todas as tabelas do schema public,
menos o histórico do Flyway; o seed vai no @BeforeEach da própria classe, que roda depois da limpeza.
Regra da casa em Java/Micronaut, Testes.
- Exceção: os testes de linha do tempo de migração (V11, V13 e V14) sobem container próprio, porque migram um schema do zero até um alvo e semeiam dado legado no meio.
- Armadilha: o
TestPropertyProviderdo Micronaut estendeSupplier, então a classe de teste não pode declarar um métodoget()— e umimport staticdeWireMock.getfica escondido pelo herdado.
Travas¶
Teste que trava vira falha em vez de segurar o CI: 2 min por teste (junit-platform.properties,
desligado sob debug), 15 min para a task test e 5 s para obter conexão do pool de teste.
Cobertura¶
O check imprime a cobertura de linhas (JaCoCo) ao final — visível, não bloqueante: piso bloqueante
seria opt-in, por decisão em decisoes/. A jacoco.toolVersion precisa ser ≥ 0.8.15, porque as
anteriores não instrumentam o class major do Java 25 e geram um falso 0%. O denominador exclui o código
gerado ($Introspection, $Definition, $Intercepted e os Serde* do micronaut-serde).
Os pontos de maior risco têm teste dedicado: JwtServiceTest (emissão e JWKS com alg=RS256),
AesCipherTest (round-trip e adulteração), PabastPasswordHasherTest (compatibilidade com os hashes de
produção) e RateLimiterTest (limites e extração de IP).
Transporte de e-mail, sem Docker. SmtpEmailSenderSocketTest sobe um SMTP de mentira num
ServerSocket local (diálogo, dot-stuffing, recusa 5xx, host fora do ar), e ResendEmailSenderTest
aponta o RESEND_ENDPOINT para um WireMock (payload, header Authorization, 2xx, 4xx/5xx, conexão
recusada). O NewAccessRequestNotifierTest prova o fail-open: transporte estourando não derruba o login.
Build¶
./gradlew shadowJar # jar executável em build/libs/app.jar
docker build -t central-backend . # imagem JVM, para build e diagnóstico locais
docker build -f Dockerfile.native -t central-backend:native-test . # a imagem de produção
Produção é native-only (decisão 0030): a CI builda o
Dockerfile.native e publica a imagem; o Coolify só a puxa.
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) |
Público
API REST — contrato público¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-25
Referência pública da API do central-backend: endpoints, corpo das requisições, respostas.
O spec é gerado do código a cada build de docs (o micronaut-openapi emite o OpenAPI a partir
dos controllers ao compilar) — é a fonte da verdade sobre o que este app expõe. Para experimentar
ao vivo, o app também serve /swagger-ui em runtime; a fonte publicada é esta página.
Quem integra com o central (ex.: a skill /xadm-setup chamando POST /api/setup/provision no
build-time de um app) linka esta página para ler o shape — em vez de re-digitar o contrato
(decisão 0020).
Como autenticar¶
As superfícies M2M usam Authorization: Bearer <token> (o /api/setup/** usa o token de serviço
do broker; o /api/admin/** aceita o CENTRAL_INTEGRATOR_TOKEN ou o JWT admin). Detalhe por rota
no spec abaixo. Tokens nunca são publicados aqui.
O contrato¶
Decisões
0001 — Framework: Micronaut 4 + Netty¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30 · Decidido em: 2026-04-01
Atualização 2026-06-30: a stack subiu para Micronaut 5 / JDK 25 — ver decisão 0009. A escolha de fundo (Micronaut sobre Spring/Quarkus, runtime Netty) registrada abaixo continua válida; mudou só a versão. O título preserva o slug publicado (constituição §5).
Equivalência com a doc arc42 legada. Esta era a documentação anterior (arc42 monolítico,
docs/*.adoc, com tabela de ADRs). O mapeamento é 1:1, na mesma ordem:ADR-001 → 0001,ADR-002 → 0002, …ADR-008 → 0008. Quem conhecia a numeraçãoADR-00Nacha a decisão em000N.
Contexto¶
O auth-service é um serviço novo, extraído do integrador, que precisa subir rápido, ter footprint pequeno (roda como container único no Coolify) e alinhar-se à stack do ecossistema xadm.biz.
Decisão¶
Usar Micronaut 4 com runtime Netty (sem Tomcat), empacotado como JAR único
(shadowJar) sobre eclipse-temurin:21.
Consequências¶
- Startup < 5 s e baixo consumo de memória — adequado a um serviço sempre-ligado e barato de escalar verticalmente.
- Mesmo modelo de programação do integrador — menos atrito de manutenção.
- DI/AOP em tempo de compilação (sem reflexão pesada), o que ajuda no startup.
Alternativas consideradas¶
- Spring Boot: startup > 15 s e footprint maior, sem ganho para este caso — descartado.
- Quarkus: não utilizado no ecossistema xadm; adicionaria uma stack a manter.
0002 — JWT RS256 com nimbus-jose-jwt¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30 · Decidido em: 2026-04-01
Contexto¶
O PowerSync valida os tokens dos apps buscando uma chave pública no JWKS do emissor. Precisamos de um algoritmo de assinatura que o PowerSync aceite e de uma biblioteca já testada no ecossistema.
Decisão¶
Emitir JWT RS256 (RSA-2048) com a biblioteca nimbus-jose-jwt — a mesma do
integrador. O JWKS expõe a chave pública com alg=RS256 explícito e um kid
configurável (AUTH_KEY_ID).
Consequências¶
- O PowerSync valida os tokens sem a chave privada, só com o JWKS público.
alg=RS256precisa ser adicionado à mão ao montar o JWKS — onimbus-jose-jwtnão o inclui por padrão, e o PowerSync recusa chaves semalg. É um ponto a não esquecer ao mexer noJwtService.- O
kidconfigurável habilita rotação futura de chaves (ver runbook de rotação).
Alternativas consideradas¶
- HS256 (simétrico): o PowerSync precisaria da chave secreta — inviável para validação por terceiros. Descartado.
- EC256: não testado com o PowerSync nesta versão; sem ganho que justifique o risco. Descartado.
0003 — pabast_senhas centralizado em db_auth¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30 · Decidido em: 2026-04-06
Contexto¶
O desenho inicial (V1) mantinha, na tabela clientes, a conexão para o banco de
cada cliente (db_host, db_user, db_pass cifrada com AES-256-GCM) e abria um
pool Hikari por cliente para ler pabast_senhas no banco de origem.
Decisão¶
Centralizar pabast_senhas em db_auth (migração V2) e remover as colunas
de conexão por cliente. O auth-service passa a consultar apenas o seu próprio
banco; os hashes chegam ao db_auth por sincronização.
Consequências¶
- Sem pools por cliente, sem credenciais de bancos de terceiros guardadas no auth-service, sem o caminho de cifra/decifra AES em runtime (o AES vira legado).
- Operação e testes mais simples — um único Testcontainers, sem multi-pool.
- A falha do banco de um cliente deixa de afetar o auth-service.
- O ERP continua a fonte de verdade; o
db_authguarda cópias sincronizadas.
Alternativas consideradas¶
- Manter pool por cliente: complexidade de operação, acoplamento à disponibilidade do banco de cada cliente e inviável acima de ~10 clientes. Descartado.
0004 — Login Firebase para administradores e usuários de apps terceiros¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30 · Decidido em: 2026-04-15
Contexto¶
Além do login pAbast (usuário do ERP), há duas audiências que autenticam por Google/Firebase: a equipe xadm (que opera a área administrativa) e usuários finais de apps terceiros que não têm credencial pAbast.
Decisão¶
Validar o Firebase ID Token (pelo JWKS público do Google) em dois endpoints:
POST /api/auth/firebase/login— exclusivo para@xadm.com.br; emite JWT comxadm_admin=true(scope=admin). E-mail de outro domínio → 403.POST /api/auth/firebase/client-login— para usuários de app terceiro; exigeclientecomoauth_firebaseemauth_methodse aprovação da equipe xadm (fila emoauth_access_requests, papéisUSER/ADMIN). Sem aprovação, cria o pedido e retorna 403.
O FirebaseTokenValidator é lazy: sem AUTH_FIREBASE_PROJECT_ID, o bean não
é registrado e os endpoints respondem 503 — o serviço roda sem Firebase.
Consequências¶
- Reutiliza a infraestrutura Firebase existente, sem novo IdP.
- O acesso de terceiros é moderado (request → approve/reject → role), com a
fila administrada por
/api/admin/oauth/firebase/requests. - Tokens Firebase > 8 KB são rejeitados de imediato (400).
Alternativas consideradas¶
- Usuário admin em
db_auth: mais operação sem ganho de segurança. Descartado. - Usar o JWT pAbast para admin: o
scopenão distingue admin de usuário. Descartado.
0005 — Rate limiting: janela fixa in-memory¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30 · Decidido em: 2026-04-01
Contexto¶
Os endpoints de autenticação são públicos e precisam de uma defesa básica contra abuso/força bruta, sem adicionar infraestrutura.
Decisão¶
RateLimiter com contador por IP em memória (ConcurrentHashMap +
AtomicInteger), reset de todos os contadores a cada 60 s (@Scheduled).
Limite: 60 req/min por IP; acima disso, 429 com Retry-After: 60. O IP real
vem de X-Real-IP/X-Forwarded-For (atrás do Traefik).
Consequências¶
- Zero dependência externa; suficiente para o volume atual (< 100 usuários por cliente) numa única instância.
- In-memory e single-instance: não persiste entre restarts nem é compartilhado. Escalar horizontalmente tornaria o limite por-instância — exigiria estado compartilhado (ex. Redis).
- Janela fixa permite uma rajada na virada de janela (até ~120 req em ~1 s) — aceito como tradeoff.
Alternativas consideradas¶
- Redis + sliding window: over-engineering para o volume atual. Descartado.
- Micronaut RateLimiter: indisponível sem configuração adicional complexa. Descartado.
0006 — Bootstrap offline com token anônimo¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30 · Decidido em: 2026-04-01
Correção 2026-06-30: o endpoint
POST /api/auth/anonestá depreciado —pabast_senhasnão é mais sincronizado via PowerSync, então o bootstrap-offline que este token habilitava deixou de ser o caminho corrente. O endpoint segue no ar só para compatibilidade com o bi-transporte. A premissa abaixo era válida quando a decisão foi tomada; não usar em integrações novas.
Contexto¶
Para fazer login offline na primeira vez, o app precisa dos hashes de
pabast_senhas no SQLite local — mas, à época, sincronizar exigia estar logado.
Ovo e galinha.
Decisão¶
Endpoint POST /api/auth/anon?cliente=X emite um JWT com
scope=pabast_only (sem autenticar usuário). O PowerSync, lendo o scope,
liberava apenas o bucket de pabast_senhas — o app sincronizava os hashes e
passava a poder logar offline.
Consequências¶
- Quebrava o ciclo ovo-e-galinha sem expor um endpoint de sync público e sem pré-popular o app à mão.
- O token não autentica ninguém — só autoriza a sincronização da tabela de hashes.
Alternativas consideradas¶
- Endpoint público de sync: inseguro. Descartado.
- Pré-popular o app manualmente: não escala para atualizações de senha. Descartado.
0007 — Admin CRUD protegido por filtro JWT dedicado¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30 · Decidido em: 2026-04-06
Contexto¶
Os endpoints administrativos (/api/admin/** — CRUD de clientes, usuários pAbast
e a fila de aprovação OAuth) só podem ser acessados pela equipe xadm, identificada
pelo claim xadm_admin no JWT.
Decisão¶
Um AdminAuthFilter (@ServerFilter("/api/admin/**")) intercepta toda
requisição administrativa, valida o JWT interno com o JwtValidator e rejeita
com 401 (ausente/inválido) ou 403 (xadm_admin falso) antes de chegar ao
controller.
Consequências¶
- Separação clara: o filtro autentica/autoriza; o controller só executa o CRUD.
- Em testes,
@MockBean(JwtValidator)isola filtro e controller sem emitir JWTs reais. - Toda rota nova sob
/api/admin/**já nasce protegida — sem repetir lógica de auth em cada método.
Alternativas consideradas¶
micronaut-securitycompleto: framework inteiro para um caso de uso simples. Descartado (à época).- Anotação por método, sem filtro: repetiria a lógica de auth em cada handler. Descartado.
0008 — pabast_senhas sem PRIMARY KEY¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30 · Decidido em: 2026-04-28
Contexto¶
O campo id de pabast_senhas é a ChaveUsu do ERP (VARCHAR(7)) e só é único
dentro de um único ERP/cliente. Com vários clientes na mesma tabela
centralizada (decisão 0003), a mesma ChaveUsu
aparece legitimamente em mais de um cliente (ex. 1 0999 em onpetro e em
vantroba), violando a PK original sobre id.
Decisão¶
A migração V6 dropa a PRIMARY KEY (id) e não coloca constraint no lugar.
A unicidade do par (cliente_id, id) passa a ser garantida em software:
AdminController e PabastSenhaRepository expõem apenas finders/updaters/deleters
por par (findByClienteIdAndId, updateMutableFieldsByClienteIdAndId,
deleteByClienteIdAndId, …). Os herdados findById/update/deleteById (que
filtram por id sozinho) ficam proibidos por convenção — cruzariam clientes.
Os endpoints admin usam …/pabast/users/{clienteId}/{id}.
Consequências¶
- Schema enxuto, sem
@EmbeddedId/@Idcomposto no Micronaut Data. - Disciplina de código: usar um finder por
idsozinho é um bug latente (cruza clientes). Está documentado noCLAUDE.mddo repositório e no guia do código.
Alternativas consideradas¶
- PK composta
(cliente_id, id)com@EmbeddedId: refator maior no entity/repositório/controllers sem ganho funcional perceptível. Descartado. - PK surrogate UUID +
UNIQUE(cliente_id, id): custo similar e adiciona uma coluna sem dono. Descartado.
0009 — Bump para Micronaut 5 e JDK 25¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-30 · Decidido em: 2026-06-30
Contexto¶
A stack da casa (engenharia/stack.md)
padronizou em JDK 25 LTS + Micronaut 5. O auth-service estava em Micronaut
4.10 / JDK 21, atrás do padrão — e desalinhado dos demais apps (ex.
bi-transporte-xls, já em Micronaut 5 / JDK 25). A decisão
0001 (Micronaut sobre Spring/Quarkus)
continua de pé; muda só a versão.
Decisão¶
Migrar para Micronaut Platform 5.0.3 (BOM), JDK 25 (toolchain Gradle) e
Gradle 9.5.1. Plugin io.micronaut.application 5.0.0; version do projeto
movido para gradle.properties (padrão da casa, lido pela /xadm-release).
Consequências¶
- Jackson 3 (
tools.jackson.*) passa a ser o backend do serde — oJwtService.jwksJson()deixou de usarcom.fasterxml.jackson.databind.ObjectMapper(Jackson 2) e serializa o JWKS via nimbus (JSONObjectUtils). Os DTOs já eram@Serdeable, então as views HTTP não precisaram mudar. - O JVM que roda o Gradle precisa ser 25 (o plugin Micronaut 5.0.0 exige) —
não basta o toolchain. Localmente:
JAVA_HOMEapontando para um JDK 25. Em CI/Docker já é JDK 25 (ci-java:25,eclipse-temurin:25). micronaut-http-server-nettyemicronaut-inject-java(AP) passam a ser declarados explicitamente; Flyway fixado em 12.x (PG 18); Testcontainers com versões explícitas (oplatform()do BOM não propaga sob Gradle 9).- O plugin/bloco de AOT foi removido (otimização opcional, não usada pela casa).
Alternativas consideradas¶
- Permanecer em Micronaut 4 / JDK 21: mantém o app divergente da stack da casa
e do ferramental de CI (imagens
ci-java:25). Descartado.
0010 — Central de Apps: modelo build-time¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-01 · Decidido em: 2026-07-01
Contexto¶
O auth evolui de serviço de autenticação para Backend de Central de Apps e Autenticação
(plano de controle dos apps da plataforma). O contrato (Camada 1,
documentacao/central-de-apps.md + constituição §7) fixou o paradigma build-time: o app
declara o que usa no app.json; a skill /xadm-setup provisiona via o auth (que tem os
tokens admin dos provedores) e grava o resultado client-grade de volta no app.json (o build
embarca). Runtime fica só com os corretores (URLs assinadas). Ver a spec .ia/005-central-de-apps.
Decisão¶
Modelo de dados novo em db_auth (Flyway V7+):
apps— catálogo porapp_idcanônico (kebab, PK natural). Guarda tambémslug/repo_url/production_url/stack/toolchain_json(o dashboard dacentralderiva os links).grupoxadm|cliente;cliente_idFK→clientes(NULL p/ xadm).app_provider_resources— recurso por (app, provider) (glitchtip|garage|aptabase|metabase), com ciclodesligada→provisionando→ligada|falha,resource_ref/client_config/secret_refem JSONB. Escopo por app (build-time), não por par.- Broker
POST /api/setup/provision(JWTxadm_admin) — valida afeature(vocabulário fechadoglitchtip|garage|analytics|docs), faz upsert do app, mapeia feature→provider(s) (analytics→ aptabase+metabase;docs→no-op), provisiona idempotente viaFeatureProvidere devolve o client-grade. FeatureProviderplugável (uma impl por provider; resolução por nome). Segredos cifrados comAesCipher; corretores runtime assinam derivados expiráveis.- Filtros por prefixo disjunto:
/api/setup/**(SetupAuthFilter, xadm_admin),/api/apps/**(corretores, JWT do usuário),/api/admin/**(AdminAuthFilter, inalterado).
Consequências¶
- JSONB no repo pela 1ª vez — mapeado via
@TypeDef(DataType.JSON)sobreMap<String,String>; provado em round-trip com Testcontainers sob Micronaut 5 / serde Jackson 3 (spike da Fase 1). - Sem tabela
app_pares(o paradigma runtime anterior a tinha): os "pares (cliente, app)" são derivados dosoauth_access_grantsdistintos; recursos de build são por app. - Os
oauth_access_grantsre-ancoram no catálogo (FKapp_id→apps) — acessos de terceiros seguem por (cliente, app). - Providers reais (GlitchTip/Garage/Aptabase/Metabase) e a injeção de secret no Coolify entram por fase, cada um investigando a API real antes (não inventar payload).
Alternativas consideradas¶
- Paradigma runtime (
GET /api/apps/config+ token de consumo por par + push de manifesto): descartado na reescrita do contrato — config client-grade é ~estática e cabe noapp.json(build-time), evitando boot-fetch e um endpoint/tokens a mais.
0011 — Observabilidade de erros (GlitchTip) e resposta honesta do /provision¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-03 · Decidido em: 2026-07-03
Contexto¶
A constituição X-Adm 0.17.5/0.17.6 ("Feedback §6 do auth") nasceu de dores desta base:
provisionar o GlitchTip do próprio auth (que é o broker da Central de Apps) custou várias
idas-e-vindas cegas porque o POST /api/setup/provision engolia falha — respondia 200 com
client_grade={enabled:true} sem dsn, e a injeção no Coolify falhava só em WARN (token com
typo → 401; IP de origem fora do allow-list → 403). Sem o JWT admin, nem o /xadm-setup nem o
operador distinguiam sucesso de falha. Em paralelo, o auth não tinha SDK que consumisse o DSN
provisionado — o pipe de erro nunca havia sido validado ponta-a-ponta.
Decisão¶
- Resposta honesta do
/provision(0.17.6). A resposta carrega, por provider, o estado real:state(ligada|falha) +last_error, e o resultado da injeção no Coolify (injected: true|false|null+injected_reason).502se nenhum provider ligou; oclient_gradesó traz refs de providersligada(nuncaenabledsem ref). O resultado da injeção é sempre reportado, nunca só logado. - GlitchTip nomeado pelo
app_id(0.17.5). Nome estável → slug estável → find-before-create confiável, sem duplicata (onome-sentença longo derivava slug feio e permitia duplicatas). - SDK de erros =
io.sentry:sentry+io.sentry:sentry-logback(8.42.0). UmSentryAppendernologback.xmllê o DSN de${SENTRY_DSN}e reporta todo log ERROR (inclui exceções não-tratadas que o Micronaut já loga) — zero código no negócio. Sem DSN (dev/test) → no-op. /test— smoke-tests públicos. Superfície não-autenticada, inócua e rate-limited para checar integrações em produção; o 1º teste dispara uma exceção benigna e valida o pipe do GlitchTip.
Alternativas consideradas¶
- Módulo
micronaut-sentry(comunidade,akyong) — não-oficial e sem compat confirmada com Micronaut 5 (bleeding edge, baseline Java 25). Preterido pelo SDK Sentry puro + appender logback, independente da versão do framework e no idioma da casa (pin explícito de lib externa). - Init programático +
ExceptionHandlercustom — mais código e superfície; captura só o que se roteia (arrisca perder exceções fora do handler). O appender logback cobre tudo que loga ERROR.
Consequências¶
- O corpo-relatório do
502sai como está — não háErrorResponseProcessor/RFC 7807 neste repo (a convenção real de erro é o map{error, message}), entãoHttpResponse.status(502).body(...)não é reescrito. injectToCoolifysaiu doProvisioningServicee virouCoolifyClient.injectEnvs(...) -> InjectionResult(testável direto por WireMock, inclusive o caminhoinjected=falsedo 401/403).- A injeção reinicia o recurso: após upsertar a env,
injectEnvschamaPOST /applications/{uuid}/restart(best-effort) — variável de ambiente não recarrega em processo vivo, então sem o restart o container seguiria com o DSN antigo até o próximo deploy. Restart que falha não desfaz a injeção (injected=true+ aviso no motivo). Trade-off: re-rodar o setup reinicia o recurso (breve indisponibilidade) mesmo quando o valor não mudou — candidato a otimização futura (reiniciar só se a env de fato mudou). providers[]reusa ostate/last_errorjá persistidos emapp_provider_resources;injectedé transitório (sem migration).- Superfície pública nova (
/test): aceitável por ser read-only, sem segredo e sem mutação, sob oRateLimiter— mas é uma decisão consciente de expor um endpoint sem auth num serviço de auth. - Rollout: re-provisionar o
authcria o projetoauthno GlitchTip; o projeto antigo (nomeado pelo nome longo) fica órfão e deve ser deletado à mão. - Casa sem recipe Sentry para java-micronaut (só Flutter tem) → candidato a feedback §6.
0012 — Garage (arquivos): bucket+key e corretor de presign broker-signed¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-10 · Decidido em: 2026-07-03
Contexto¶
Fase 5 da Central de Apps: o provider de arquivos (Garage — S3-compatível, Admin API v2 em
arquivo-admin.xadm.biz). Por app: 1 bucket + 1 access key read/write escopada. É o 1º
provider com secret per-app (o secretAccessKey), então define como o segredo é guardado e como
o app acessa os arquivos sem que o segredo vaze para o device.
Decisão¶
- Provisão idempotente (
GarageProvider):GetBucketInfo?globalAlias=<app_id>-or-CreateBucket→ reusa a key associada ao bucket (GetKeyInfo?showSecretKey=true) ouCreateKey+AllowBucketKey(read+write,owner:false). A idempotência da key usa okeys[]do bucket — sem inventar umListKeys.client_config = {endpoint, bucket, region}(→app.json, público). OsecretAccessKeyé cifrado (AesCipher/AUTH_CIPHER_KEY) emsecret_ref— nunca sai do auth. - Acesso = presign broker-signed (decisão 4.4a): o
GarageBrokerController(GET /api/apps/garage/presign, sobAppUserAuthFilter) devolve URLs presignadas curtas (SigV4, 10 min) para GET/PUT. A write-key só é decifrada no servidor para assinar; o device recebe só a URL. Blast-radius de device comprometido = poucas URLs expirando. - Autz por app (I2): o token de usuário carrega
oauth_app_id; o filtro o repassa e o corretor exigeapp_id == oauth_app_id→ 403 cross-app. Um JWT de app A não assina recurso de app B. - Assinatura = AWS SDK v2
S3Presigner(software.amazon.awssdk:s3, path-style, endpoint override) — implementação de referência do SigV4, offline (não faz I/O S3).
Alternativas consideradas¶
- Entregar a write-key ao app (4.4b): zero round-trip, mas o segredo bucket-wide vive em todo device (extração do APK compromete o bucket até rotacionar). Preterido — só aceitável com key/bucket por-usuário.
- SigV4 à mão (sem dep): erro-próprio/sensível a segurança. Preterido pela AWS SDK.
- MinIO SDK: mais leve, mas a AWS SDK v2 é a referência.
Consequências¶
- Injeção da write-key no Coolify (server/web) — HABILITADA (2026-08-10, o gancho deferido acima).
Para app server/web (tem
production_url→ recurso Coolify), o broker injeta, além do client-grade, a write-key:GARAGE_ACCESS_KEY_ID+GARAGE_SECRET_ACCESS_KEY(decifrada desecret_refno ato da injeção, viaAesCipher/AUTH_CIPHER_KEY). O app faz S3 direto (1 hop), sem o round-trip de presign. Justificativa: um server é infra confiável (env tão protegido quanto o do auth), então o ganho de segurança do presign — manter o segredo fora de device não-confiável — não se aplica; e a key é per-app, escopada só ao bucket do próprio app (AllowBucketKey,owner:false), então o blast-radius de um server comprometido é o próprio bucket (rotação = re-rodar/xadm-setup+ restart). O secret entra só no Coolify (mascarado poris_shown_once), nunca noapp.json(que leva só o client-grade público). Mobile/local (semproduction_url) não recebe a write-key — para device continua valendo o presign broker-signed (item 2), o segredo fica no auth. Ou seja: presign para device, S3 direto para server — coexistem. - Config nova (recurso
auth):GARAGE_ADMIN_URL,GARAGE_ADMIN_TOKEN,GARAGE_S3_ENDPOINT,GARAGE_S3_REGION. Ausentes → o provider recusa provisionar (boot ok). - Nova superfície runtime
/api/apps/**(corretores), guardada peloAppUserAuthFilter(JWT de usuário, valida via a chave local). OJwtValidator.JwtClaimspassou a exporoauth_app_id. GARAGE_ALLOW_WORLD_READABLE_SECRETS=trueno cluster permite re-obter o secret (GetKeyInfo ?showSecretKey) na re-provisão — pilar da idempotência da key.
0013 — Analytics (Aptabase): auto-provisão forjando o token de sessão¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-03 · Decidido em: 2026-07-03
Contexto¶
Fase 6 da Central de Apps: a feature analytics. Apps X-Adm (Flutter) usam Aptabase
(self-hosted); a app_key (A-SH-*, pública) era criada na mão e hardcoded. O broker deve
provisionar — mas o Aptabase não tem API de provisão (auth é magic-link/sessão; o
AppsController é [IsAuthenticated], sem API token — verificado na fonte aptabase/aptabase).
Decisão¶
- Aptabase provisiona os eventos da feature
analytics. (À época,analytics = [aptabase]só — o Metabase estava fora. Atualizado pela decisão 0014: o Metabase foi re-escopado para dentro deanalytics, que agora provisiona[aptabase, metabase].) - Auto-provisão forjando o token de sessão. O magic-link do Aptabase é um JWT HS256 assinado
com o
AUTH_SECRET(bytes ASCII; claims{type, name, email, iss:"aptabase-sh", exp}— verificado emAuthTokenManager/EnvSettings). O broker forja um token deRegister(o/continuecria a conta setup se não existe e loga) e abre sessão: - forja o JWT (HS256,
AUTH_SECRET). GET /api/_auth/continue?token=…→ cookie de sessão (sem seguir o redirect).GET /api/_apps→ lista-antes-de-criar (reusa por nome=app_id).POST /api/_apps {name: app_id}→app_key(client-grade →app.json). Sem e-mail, sem log, sem Coolify.- Idempotência: list-before-create no Aptabase + reuso da key persistida no
ProvisioningService(não re-faz o fluxo quando jáligada). - Sem corretor runtime (o app manda eventos direto ao ingest com a app_key pública).
Operar (config no recurso auth)¶
Provisão 100% automática via /xadm-setup (feature analytics) → POST /api/setup/provision.
Não há runbook separado (não há procedimento manual — o broker forja o token e cria/reusa o app).
O operador só liga a feature setando estas envs uma vez no recurso auth (Coolify):
| Variável | Valor | Secret? |
|---|---|---|
APTABASE_ADMIN_URL |
https://aptabase.xadm.biz |
não |
APTABASE_SETUP_EMAIL |
e-mail da conta setup (ex. setup@xadm.biz) — o /continue cria se não existe |
não |
APTABASE_SETUP_NAME |
nome da conta (default X-Adm Setup) |
não |
APTABASE_AUTH_SECRET |
= o AUTH_SECRET do serviço Aptabase (mesmo valor) |
sim ✅ |
Opcional APTABASE_ISS (default aptabase-sh). Ausência das obrigatórias → o provider recusa (boot
ok). Não precisa de APTABASE_COOLIFY_UUID nem de COOLIFY_*.
Pegar o AUTH_SECRET (no host do Coolify, terminal):
docker inspect $(docker ps --filter "name=aptabase" -q | head -1) \
--format '{{json .Config.Env}}' | tr ',' '\n' | grep -i AUTH_SECRET
APTABASE_AUTH_SECRET.
Diagnóstico — o /provision responde providers[].last_error (fail-fast, nunca key falsa):
falha ao forjar o token — AUTH_SECRET < 32 bytes? (secret vazio/curto); continue HTTP 4xx — token
rejeitado (APTABASE_AUTH_SECRET ≠ o real, ou iss errado); GET /_apps … (sessão não abriu?)
(o cookie do /continue não pegou — mudança de versão?).
Alternativas consideradas (a saga)¶
- Login e-mail+senha: inválido — o Aptabase não tem senha (magic-link).
- Manual-register (dev cria na UI + informa a key): funcional, mas o alvo era 100% automático.
- Raspar o magic-link do log do Coolify: inviável — o Aptabase é um Coolify service
(compose) e a API do Coolify não expõe logs de service (só de
application— verificado emroutes/api.php). Descartado. - Forjar o token (escolhido): robusto, independe de logs/infra, funciona pra service.
Consequências / riscos¶
- Segurança — credencial poderosa: o
AUTH_SECRETno env do broker permite forjar sessão de qualquer usuário do Aptabase. Aceito como infra do broker (é o mesmo nível dos outros admin tokens que oauthguarda), mas é o secret mais sensível dessa feature — só no env doauth. - Guardas (endpoints/claims internos, não-documentados): versão do Aptabase fixada
(
main:/api/_auth/continue,/api/_apps, claimtype=Register,iss=aptabase-sh) — reconferir em upgrade. Fail-fast: qualquer passo falho (forja, continue 4xx, sessão, parse) →state=falha+last_error; nunca key falsa (resposta honesta 0.17.6). - Config nova (recurso
auth): ver Operar acima (envs + como pegar oAUTH_SECRET).APTABASE_COOLIFY_UUIDnão é mais necessário. - Lado Flutter (fora do
auth): oclient_configtrazaptabase_key+aptabase_host— a keyA-SH-self-hosted é inútil no cliente sem o host, porque o SDK Flutter exigeInitOptions(host:). O host é o próprioAPTABASE_ADMIN_URL(URL pública da instância; admin e ingestão no mesmo host). O/xadm-setupgrava ambos emfeatures.analyticsnoapp.jsone scaffolda o helper (mirror sulplata); vêm doapp.json, não hardcoded.
0014 — Analytics BI (Metabase): auto-provisão via API REST¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-03 · Decidido em: 2026-07-03
Contexto¶
A feature analytics da Central de Apps provisiona o Aptabase (eventos —
decisão 0013). A 0013 descopou o Metabase ("BI independente; o app
não o consome"), então a estrutura de BI (collection do cliente, dashboard do app, cards com queries
ClickHouse sobre a tabela events) era criada na mão — repetitivo, propenso a erro e a
divergência entre apps.
Diferente do Aptabase (sem API de provisão), o Metabase tem API REST real — dá pra provisionar de forma robusta e idempotente. Esta decisão reverte o descopo e implementa o passo Metabase.
Decisão¶
- Metabase re-escopado para dentro de
analytics.FEATURE_PROVIDERS["analytics"] = ["aptabase", "metabase"]; a resposta honesta (0.17.6) reporta os dois sub-estados (state/last_errorpor provider); a falha de um não impede o outro. MetabaseProvidergarante (procura-antes-de-cria §7, organização em 2 níveis): Collection do grupo/cliente (nome =cliente_id; sem cliente →"X-Adm"; match por nome na raiz + não-archived, pois nome de collection não é único) → sub-pasta do app (nome =app_id,parent_id= a do grupo/cliente) → Dashboard do app (porapp_id) → cards baseline (query nativa ClickHouse, dbMETABASE_CLICKHOUSE_DB_IDdefault2,<APP_ID>substituído), tudo dentro da sub-pasta do app (X-Adm|cliente → app → dashboard + cards).- Cards com nome canônico, sem sufixo — já isolados na sub-pasta do app, então não colidem entre
apps (a sub-pasta substitui o antigo sufixo
· app_id). Idempotência pelosdashcardsdo dashboard + reuso por nome na collection (adota cards órfãos de uma falha parcial, sem duplicar); ligados por read-modify-write (GET /api/dashboard/:id→ mescla →PUT <!-- checa-rotas: ignorar --> /api/dashboard/:idcom dashcards de id negativo único — a versão do Metabase rejeitaid:-1repetido; oPOST /:id/cardsé deprecado ≥ v0.47). - Baseline 5 sempre (Usuários Ativos, Sessões Ativas, Usuários Ativos por Dia, Versão do
App, Ações Mais Usadas — que já agrega todo evento custom via
NOT IN). + Telas Mais Usadas só sescreen_view ∈ eventos(o app manda oseventosna provisão; ausente/vazio → inclui). OAppcarregaeventoscomo campo transiente (não persiste). - Período = duas variáveis
data_inicio/data_fim(type:date) — não field filter (evita lookup defield_ide dependência do sync do ClickHouse no Metabase). - BI = só link: devolve
metabase_dashboard_id(client-grade →app.json); o app não embeda (sem signed embedding). Idempotência entre runs = reuso-persistido (allowlist{aptabase, metabase}noProvisioningService).Superado em parte pela decisão 0017: o "não embeda / sem signed embedding" foi revertido — agora o auth habilita
enable_embeddinge minta a URL de embed assinada (AppLinks.metabase_embed), mantendo o link como fallback. O resto da 0014 segue.
Alternativas consideradas¶
- Manter o Metabase fora (0013): provisão manual não escala; a API existe → automatizável.
- Field filter de range (
{{periodo}}→ um widget): exigefield_iddeevents.timestamp+ ClickHouse sincronizado — mais frágil. Preterido pelas duas variáveis. - Criar os 6 cards incondicionalmente: simples, mas Telas viria vazia sem
screen_view. Preterido — o app já informa oseventos. - Cards bespoke por evento custom: deferido (editorial, não automatizável; o Ações Mais Usadas já cobre os custom).
- Signed embedding no app: fora de escopo (mais superfície; o link basta).
Consequências / riscos¶
- Versão mínima do Metabase (guarda): API keys (
x-api-key) exigem ≥ 0.49;PUTcomdashcardsexige ≥ 0.47/0.48. Versão fixada (reconferir em upgrade); instância mais velha → 401/404 → falha honesta (fail-fast, nunca id falso). - Match de collection por nome (não-único): mitigado por raiz + não-archived +
collection_idpersistido (reuso); risco residual aceito. visualization_settingsmínimo por tipo — card pode renderizar pobre (não quebra); ajuste fino é operação.
Operar (config no recurso auth)¶
Provisão automática via /xadm-setup (feature analytics). Sem runbook separado (provider
automagico não tem procedimento manual). Envs a setar uma vez no recurso auth (Coolify):
| Variável | Valor | Secret? |
|---|---|---|
METABASE_URL |
https://metabase.xadm.biz |
não |
METABASE_API_KEY |
API key de escrita (Admin → API Keys; grupo com escrita) | sim ✅ |
METABASE_CLICKHOUSE_DB_ID |
id do DB ClickHouse "Aptabase Clickhouse" (default 2) |
não |
Ausência de METABASE_URL/METABASE_API_KEY → o provider recusa (boot ok). Diagnóstico: o
/provision responde providers[].last_error (ex.: Metabase POST /api/card HTTP 401 = API key
inválida/sem escrita; HTTP 404 = versão do Metabase antiga ou db id errado).
Lado do app (fora do auth)¶
O /xadm-setup manda os eventos do app no request de provisão e grava
features.analytics.metabase_dashboard_id no app.json (link pro dashboard). Ver
decisão 0013 para o lado Aptabase da mesma feature.
0015 — Filtros admin com @ServerFilter (honra @Order)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-04 · Decidido em: 2026-07-04
Contexto¶
O browser da Central (central.xadm.biz) passou a receber "No 'Access-Control-Allow-Origin'
header is present" em toda chamada a /api/admin/**. O preflight OPTIONS respondia 401
em produção, apesar de o código estar correto e o teste
AdminControllerTest.cors_options_preflight_returns200WithHeaders passar verde.
A causa foi confirmada empiricamente rodando o fat jar (o mesmo artefato que o Coolify
builda) localmente: o OPTIONS caía no AdminAuthFilter (401) antes
do AdminApiCorsFilter, que deveria curto-circuitar o preflight.
Os dois filtros usavam a API legada @Filter + implements HttpServerFilter, ordenados só por
@Order. O HttpServerFilter legado ordena pelo método getOrder() e ignora a anotação
@Order. Com ambos no default, a ordem ficava indefinida — e divergia por ambiente:
- Classpath (teste
@MicronautTest): a varredura punha o CORS antes → preflight tratado → verde. - Fat jar (produção): a ordem das entradas do zip invertia → Auth primeiro → 401.
Por isso o gate nunca pegou: teste e produção exercitam ordens de filtro diferentes.
Decisão¶
Migrar os dois filtros admin para a API @ServerFilter do Micronaut 5
(@RequestFilter / @ResponseFilter), que honra @Order de forma determinística tanto no
classpath quanto no fat jar. Ordem: AdminApiCorsFilter = HIGHEST_PRECEDENCE, AdminAuthFilter
= HIGHEST_PRECEDENCE + 100.
Como defesa-em-profundidade, o AdminAuthFilter exime OPTIONS (passa adiante): um
preflight nunca leva Authorization, então negá-lo com 401 é sempre errado — e assim, mesmo que
a ordem se inverta de novo, o preflight não quebra.
A decisão 0007 (admin protegido por filtro JWT) continua de pé; muda só a API de filtro e a garantia de ordem.
Consequências¶
- Regressão travada por teste que reflete produção:
AdminFilterOrderingTestverifica o invariante de ordem (uso de@ServerFilter+ CORS antes do Auth) e a eximição deOPTIONS— guardas que o teste de integração antigo, dependente da sorte do classpath, não dava. - Respostas de erro (ex.: 401) em
/api/admin/**agora também carregamAccess-Control-Allow-Originquando a origem é permitida — o browser consegue ler o erro em vez de mascará-lo como falha de CORS. - Os demais filtros legados (
AppUserAuthFilter,SetupAuthFilteremcentralapps) têm o mesmo risco latente de ordem indefinida se algum dia dividirem path com outro filtro. Não foram migrados aqui (sozinhos no path, sem sintoma) — migrar é trabalho futuro.
Alternativas consideradas¶
- Sobrescrever
getOrder()mantendoHttpServerFilter: corrige a ordem (verificado no fat jar), mas mantém a armadilha — o próximo filtro legado com só@Ordervolta a falhar em silêncio. A migração para@ServerFilterremove a classe do bug. - Só eximir
OPTIONSno Auth, sem mexer na ordem: resolveria o preflight, mas deixaria a ordem dos dois filtros indefinida para o resto (ex.: decoração de resposta). Insuficiente sozinha.
0016 — Contrato de erro único (RFC 7807 / problem+json)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-07-06
Contexto¶
A auditoria §6 (constituição 0.17.35, arquitetura destilada dos pilotos) apontou que o auth
montava corpos de erro ad-hoc — HttpResponse.status(...).body(Map.of("error", ..., "message", ...))
— espalhados por controllers e filtros (~50 pontos, ~28 códigos distintos). A norma da casa
(bi-transporte-xls, decisão 0017)
é um corpo único RFC 7807 (application/problem+json) renderizado por um
ErrorResponseProcessor que @Replaces o HateoasErrorResponseProcessor default.
Dois fatos do auth impedem uma cópia literal do modelo dos BIs:
- Códigos de máquina são load-bearing. O
error(ex.:invalid_token,user_exists,cliente_not_found) é consumido pela Central e travado em teste. O processor dos BIs deriva o código dostatus.name()— o que colapsaria os ~28 códigos (vários sob o mesmo status:missing_tokeneinvalid_tokensão ambos 401). - Os endpoints públicos (
AuthController) são consumidos por browser e hoje adicionam CORS a toda resposta — inclusive erros — viawithCors(...). UmErrorResponseProcessorglobal não adiciona CORS; sem ele, o browser bloquearia a leitura do corpo de erro.
Decisão¶
Adotar o problem+json como contrato único de erro, com três peças em comum/error:
ProblemDetail— record RFC 7807 (type,title,status,detail) + a extensãocode(o antigoerror, machine-code estável). Desde a const 0.28.0 vem da libbr.com.xadm:xadm-comum-web(br.com.xadm.comum.web.ProblemDetail), não mais do fork local — ver Atualização abaixo.ApiException extends HttpStatusException— carrega status +code. Diverge do modelo classe-por-status dos BIs de propósito (o auth tem ~28 códigos, muitos sob o mesmo status → o código é carregado pela exceção, não derivado do status). Fica local (é domínio do auth) e implementabr.com.xadm.comum.web.PortadoraDeProblema— o ponto de extensão pelo qual o processor da lib lê o machine-code (ver Atualização).UnifiedErrorResponseProcessor— renderiza os erros gerados pelo framework (validação, 404, exceções) em problem+json. Desde a const 0.28.0 vem da lib (xadm-comum-web:0.3.0): lê ocodedaPortadoraDeProblemana causa-raiz (fallbackstatus.name()) e monta a lista de campos no Bean Validation. O fork local foi removido — ver Atualização.
Fluxo por superfície:
| Superfície | Como sinaliza erro | Quem renderiza |
|---|---|---|
Filtros (AdminAuthFilter, AppUserAuthFilter, SetupAuthFilter) |
throw ApiException |
processor global |
| Controllers admin/setup/apps | throw ApiException |
processor global |
AuthController (público, browser) |
throw ApiException |
@Error local → problem+json + CORS |
O @Error(exception = ApiException.class) local no AuthController é o ponto-chave: ele
renderiza problem+json com os headers CORS (withCors), preservando o contrato do browser que
o processor global não daria. Por ser local, só vale para as rotas do próprio controller — os
demais seguem no processor global (sem CORS, correto: admin tem seu próprio CORS filter; apps/setup
não são browser).
Exclusões deliberadas (não são erros, não viram problem+json):
- Relatório 502 do
SetupController— corpo de sucesso (relatório de provisão) devolvido diretamente; respostas retornadas não passam pelo processor, então seguem preservadas. - 403
{status: PENDENTE|SPAM}do client-login — sinal de fluxo que o app consome (não uma falha); modelado comoAuthLoginService.ClientLoginResult, não comoApiException.
Consequências¶
- Contrato de wire: todo erro de API agora é
application/problem+jsoncom{type, title, status, detail, code}; ocodepreserva o machine-code antigo. Travado porPabastLoginControllerTest.erroDeLogin_saiComoProblemJsonComCors(erro público = problem+json + CORS) e pelos testes de contrato doAdminController(assertamcode). - Item 1 (§6):
AppUserAuthFiltereSetupAuthFiltermigrados para@ServerFilter+@RequestFilter— completa o "trabalho futuro" previsto na decisão 0015. - Item 4 (§6): o
AuthController(615 → ~270 linhas) virou fino — HTTP (rate limit, CORS, mapeamento) no controller, negócio no novoAuthLoginService; DTOs e o contratoFirebaseTokenValidator/FirebaseClaimsextraídos para arquivos próprios. - Item 2 (§6): trava de arquitetura
ArchitectureTest(ArchUnit) — verArchitectureTest; sem ciclos entre features,comumindependente.
Alternativas consideradas¶
- Classe-por-status (modelo literal dos BIs): rejeitado — colapsaria os ~28 machine-codes num código por status, quebrando o contrato consumido pela Central.
- Processor global adiciona CORS: rejeitado — CORS
*em todo erro estaria errado para/api/admin/**(origens específicas viaAdminApiCorsFilter) e é irrelevante para apps/setup. - Confiar no
@CrossOriginpara o CORS do erro (sem@Errorlocal): não escolhido — o comportamento do CORS filter sobre respostas de exceção não é garantido entre versões; o@Errorlocal é explícito e testado. - Converter só o processor, deixar os
Map.ofad-hoc: rejeitado pelo dono — o pedido foi contrato uniforme, não meia-adoção.
Atualização — adoção do xadm-comum-web (constituição 0.28.0)¶
Na migração 0.18.4→0.28.0 o auth passou a consumir a lib da casa (br.com.xadm:xadm-comum-web,
decisão 0019 do central)
em vez de manter o fork local do contrato de erro:
ProblemDetail: adotada (record idêntico; drop-in). Fork local removido.UnifiedErrorResponseProcessor: adotado a partir dexadm-comum-web:0.3.0. A 0.2.0 não servia (derivava ocodesempre destatus.name(), colapsando os ~28 machine-codes do auth); o gap virou feedback ao central, e a 0.3.0 fechou a pendência: o processor lê o machine-code viaPortadoraDeProblemana causa-raiz — a nossaApiExceptiona implementa — e carimba a lista de campos no Bean Validation. O fork local do processor foi removido; o contrato de erro do framework é 100% da lib.ApiException: fica local (é domínio do auth: os 28 codes), agora implementandoPortadoraDeProblema.
/health — resolvido na lib (xadm-comum-web:0.4.0). A dep traz um HealthController
@Controller("/health") (para apps sem micronaut/management). Na 0.3.0 ele colidia com o
/health do micronaut-management do auth → duas rotas GET /health → o Micronaut respondia
400 ("More than 1 route matched"), quebrando o healthcheck. Micronaut não remove a rota de um
@Controller de lib (nem @Replaces nem bean-exclude); virou feedback ao central. A 0.4.0 tornou
o HealthController condicional (@Requires(missingBeans = io.micronaut.management.endpoint.health.HealthEndpoint)):
em app com management (o auth) ele se cala, e o /health DB-aware (JDBC/disk/deadlock +
/health/liveness·/health/readiness) do management vale. endpoints.health.enabled: true.
Regressão travada em HealthRouteTest (/health == 200 {status:UP}).
Emenda 2026-09-11: depois disto o /health foi rebaixado ao flat da lib (e708fdb, 2026-08-21)
e, com a xadm-comum-web 0.6.1 (6f4138b, 2026-08-26), o HealthController/InfoController da lib
ficaram incondicionais e o management teve /health e /info desligados
(endpoints.health.enabled: false). O /health de hoje é o flat {status, versao, flavor, commit}
da lib, 200 fixo, igual em jar e native — não há mais /health/liveness·/health/readiness.
micronaut.security.enabled: false — postura do hub, não limite da lib. A lib traz
micronaut-security transitivo (para o @Secured do seu HealthController). Isso é by-design:
a casa padroniza todo app consumindo o framework de segurança (xadm-seguranca). O auth é o caso
especial (CLAUDE.md): é o emissor de JWT / provedor de JWKS — a coisa contra a qual a security
dos outros apps valida —, então não age como resource-server do framework; autentica por
@ServerFilter próprio (Admin/Setup/AppUser). Desligar a security é a postura correta do hub, não um
contorno de bug. Um app comum (com security ligado) consome a lib sem essa flag.
0017 — Metabase signed embedding (supersede o "BI = só link" da 0014)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-07-06
Contexto¶
A decisão 0014 §6 fixou "BI = só link": o auth devolve
metabase_dashboard_id e a Central abre o dashboard numa aba — sem embutir. O motivo era evitar
superfície: signed embedding exige um embedding secret no env do auth e um iframe no painel.
O trabalho de padronização da Central (authui, .ia/003, L9) pediu o dashboard embutido no
detalhe do app (melhor UX que pular pra outra aba). Isso reverte o §6 da 0014 — uma decisão
explícita (REGRA Nº 3), registrada aqui.
Decisão¶
Adotar signed embedding do Metabase, mantendo o link como fallback. Três peças no auth:
MetabaseProviderhabilita embedding no dashboard (A2): ao provisionar/reusar,GETo dashboard e, seenable_embeddingnão étrue, fazPUT /api/dashboard/:id <!-- checa-rotas: ignorar --> {enable_embedding:true}(idempotente, atualização parcial). Sem o flag, o Metabase recusa/embed/dashboard/<jwt>com 400. Best-effort e por último (depois dos cards): se o Static Embedding está off na instância (o default do A0), oPUT400a — a falha é engolida comWARN, não derruba a provisão. Degrada pra "dashboard com cards, sem embed" (a Central usa o link simples), nunca "dashboard vazio". Antes oenable_embeddingrodava antes dos cards e era fatal → numa instância sem A0 a provisão estourava com o dashboard já criado e vazio.MetabaseEmbedSignerminta a URL de embed assinada (A2): JWT HS256 com oMETABASE_EMBEDDING_SECRET(payload{resource:{dashboard:<id>}, params:{}, exp}), no mesmo padrão do token forjado do Aptabase; a URL é<metabase>/embed/dashboard/<jwt>#bordered=true&titled=true, mintada em read-time (TTL curto, default 10 min) peloAppCatalogControllere exposta emAppLinks.metabase_embed, só quando o providermetabaseestáligada.- Links contextuais resolvidos server-side (A1): o
AppCatalogControllertambém resolveAppLinks.glitchtip(<glitchtip>/<org>/<slug>) eAppLinks.metabase(<metabase>/dashboard/<id>) dos refs persistidos — a UI não deriva URL de slug/repo (o DSN do GlitchTip não carrega o slug).
Graceful sem A0: enquanto o METABASE_EMBEDDING_SECRET não estiver no env, o signer devolve
empty → metabase_embed é null → a Central cai no link simples. Ou seja, este código é
deployável antes do A0 (habilitar signed embedding no Metabase + prover o secret): acende sozinho
quando o secret aparecer.
Justificativa da superfície (que a 0014 evitava)¶
- Embedding secret no env do auth: é do mesmo tipo do
METABASE_API_KEYe doAPTABASE_AUTH_SECRETque o auth já guarda (secrets de provider, cifrados no Coolify) — não é uma classe nova de risco. O JWT de embed é read-only, escopado a um dashboard, com TTL curto (≠ o api-key de escrita). - Iframe no painel admin: o embed roda só no painel admin (
/api/admin/apps, atrás doAdminAuthFilter+ JWTxadm_admin), não numa superfície pública. O app do usuário final não embeda (continua no link). - Só-web: o
HtmlElementView(iframe) é do lado authui, condicional akIsWeb(fallback link fora do web). O auth só entrega a URL.
Consequências / riscos¶
- Verificação real pendente do A0. O
enable_embeddingPUT e a URL assinada não foram exercitados contra um Metabase real (exige o A0: signed embedding habilitado + o secret). O contrato do payload de embed (resource/params/exp, HS256) segue a doc do Metabase; reconferir ao habilitar o A0. Cobertura atual: unit test do signer (assinatura + graceful sem secret) + WireMock doenable_embeddingPUT (habilita quando ausente e degrada não-fatal quando a instância recusa com 400, com os cards presentes) + teste de resolução de links (A1). - Rotação do embedding secret invalida as URLs em voo (TTL curto → impacto pequeno).
- Versão do Metabase: signed embedding é estável há muitas versões; o guard de versão da 0014 (API keys ≥ 0.49) já cobre o mínimo.
Operar (config no recurso auth)¶
Além dos envs da 0014:
| Variável | Valor | Secret? |
|---|---|---|
METABASE_EMBEDDING_SECRET |
Admin → Settings → Embedding → Static embedding → Embedding secret key | sim ✅ |
METABASE_EMBED_TTL_SECONDS |
TTL da URL assinada (default 600) |
não |
A0 (infra, uma vez): Admin → Settings → Embedding → habilitar Static embedding e copiar a secret key pro env do auth. Sem isso, a Central usa o link simples (graceful).
Alternativas consideradas¶
- Manter "só link" (0014): pula pra outra aba — UX pior; o pedido L9 é embutir.
- Public embedding (dashboard público, sem assinatura): expõe os dados sem controle — recusado.
- Interactive / SSO embedding: fora de escopo (mais integração; o static embedding basta).
- Mintar a URL no provision (persistida): o JWT tem
exp— persistir venceria. Mintar em read-time resolve.
0018 — Habilitação GraalVM native-image do auth¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-08-19
Atualização (reconciliação — modelo de build convergiu): esta decisão descreve o build native via
dockerBuildNative(pluginio.micronaut.application/wolfi) como O método. A casa depois convergiu para o modelo Docker: umDockerfile.nativehand-written na raiz (buildernative-image-community:25i2-ol8→./gradlew nativeCompile -PnativeCI→ runtimeubuntu:24.04), buildado e publicado pela CI (jobbuild_nativedo.github/workflows/pipeline.yml, tagsv*) emfonte.xadm.biz/xadm/central-backend:native-amd64, que o Coolify puxa via Build Pack Docker Image (decisão central 0022 §Topologia). O que segue abaixo vale — perfil de otimização (-Os/-Ob,--gc=serial,-march=compatibility), o gate e2e, os achados de reflect-config e/health/liveness— só a ferramenta de build mudou (dedockerBuildNativeparanativeCompiledentro doDockerfile.native). O histórico abaixo é preservado.
Contexto¶
O auth roda como fat JAR na JVM (Temurin 25). É o SPOF do ecossistema (emissão/validação de
JWT do PowerSync, login pAbast/Firebase, broker da Central) e um container always-on no Coolify —
onde a RAM da JVM (~300–500 MB) pesa no custo e o boot na casa dos segundos alarga a janela de
indisponibilidade em restart/rollback. GraalVM native-image resolve os três: memória ~5–10× menor,
boot em ms, e alinha o auth ao padrão native já piloteado na casa (bi-transporte-xls).
O risco: AOT quebra o que depende de reflection/resource em runtime, e num broker isso só aparece
no path exercitado. As superfícies temidas eram: AWS SDK v2 S3Presigner (reflection pesada),
json-smart do Nimbus (JWKS/JOSE), resources do Flyway (db/migration/*.sql) e o
SentryAppender do logback. Firebase foi confirmado sem Admin SDK (validação é Nimbus +
JWKS via HttpClient), o que já reduziu a superfície.
Decisão¶
Habilitar o build native via o plugin io.micronaut.application 5.0.0 (que aplica o
native-build-tools transitivamente — o accessor graalvmNative {} não é gerado no Kotlin DSL, daí
configure<GraalVMExtension>). Perfil de otimização: -PnativeQuick → -Ob (build rápido pro
loop de dev/e2e) e, sem a flag, -Os (imagem menor, prod), sempre com --gc=serial. CE 25
não tem G1/PGO/build-report (Oracle-only). Build off-host (dockerBuildNative); o Coolify puxa
a imagem pronta.
O gate de adoção é o e2e native (etc/tests/native/e2e-central-backend/, irmão do piloto): sobe a imagem
native em Docker e exercita as 3 superfícies de risco — login pAbast → JWT → verificação da
assinatura, validação de Firebase ID token (via stub de JWKS), e presign S3 full-path. Nenhum
reflect-config é adivinhado em massa: a lista sai do loop (roda → quebra → registra → repete).
Consequências¶
- A conversão foi quase de graça. Das superfícies temidas, só o
SentryAppenderprecisou de reflect-config manual —META-INF/native-image/br.com.xadm.integracao/central/reflect-config.json, herdado do piloto (o logback instancia o appender por reflexão no boot). Emenda 2026-09-11: a cópia registrava só a classe, e no native o<options>dologback.xmlera ignorado em silêncio (evento semenvironment); a hint passou a vir daxadm-comum-web0.9.1, com as três entradas (appender,SentryOptions,Level.valueOf), e a cópia local saiu. AWS SDK v2, json-smart e Flyway não precisaram de config manual: - AWS SDK v2 (
s3:2.44.13): a reachability-metadata embarcada é suficiente — oS3Presignerassina SigV4 (X-Amz-Algorithm=AWS4-HMAC-SHA256) em native sem config. Era o maior risco previsto. - json-smart (Nimbus): o JWKS (
{"keys":[...]}comalg=RS256) serializa em native; JWT RS256 é emitido e a assinatura verifica (openssl dgst -verify= Verified OK). - Flyway:
io.micronaut.flyway.StaticResourceFeatureregistra as migrations automaticamente — as 9 migrations aplicam no boot native. - JCA é stock JDK:
SunRsaSign(RSA/SHA256withRSA) +SunJCE(AES-256-GCM doAesCipher, HmacSHA256 do SigV4). Sem BouncyCastle, sem provider custom a registrar. - Métricas medidas (imagem
-Ob): boot/healthem ~1 s, RSS ~43 MiB, imagem 214 MB. - Novo seam de produção (baixo risco):
auth.firebase-jwks-url(AUTH_FIREBASE_JWKS_URL, default = Google). Existe porque a imagem native é black-box e não expõe o seam in-process (FirebaseTokenValidatorService.jwksUrl) que os testes JVM usam; o e2e native aponta o JWKS para um stub. Default preserva o comportamento de produção. - Paridade
/health/liveness(achado do rollout canário): sob AOT o grupo liveness default fica sem indicador (o detector de deadlock viaThreadMXBeannão reporta) →/health/liveness=UNKNOWNno native vsUPna JVM (todo o resto é idêntico). Não quebra deploy (/healthusa os indicadores JDBC/disk e ficaUP; UNKNOWN é 200, não DOWN), mas rompe a paridade e enganaria um probe que checastatus==UP. Fix: umLivenessHealthIndicator@Liveness @Singletonexplícito (bean compile-time → native-safe, sem reflect-config) que reportaUPnos dois runtimes. Descartado registrar oThreadMXBean/java.lang.managementno reflect-config (reintroduz reflection AOT por um ganho marginal num broker stateless). Emenda 2026-09-11: o indicador saiu quando o/healthvirou o flat da lib (e708fdb/6f4138b, ago/2026, com o management desligado) — este app não expõe mais/health/liveness. - O
DockerfileJVM permanece — é o lado JVM do rollout canário (ver runbook).
Alternativas consideradas¶
- Adivinhar reflect-config em massa (AWS SDK, json-smart, Flyway) antes do e2e: descartado — teria inflado a config com entradas desnecessárias; o e2e provou que nenhuma era precisa.
- Cortar 100% pra native sem canário: inaceitável num SPOF — ver o runbook (rollout canário JVM+native, collapse só após validação em produção).
-Ostambém no loop de dev: mais lento por iteração;-PnativeQuick/-Obacelera o loop e o perfil final de prod (-Osvs-Ob) fica decidido ao medir tamanho/boot no corte.
0019 — Monitor de host/apps do Coolify (Fase 1, read-only)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-08-19
Contexto¶
A VM de produção bi-ubuntu (host do Coolify) congelou durante deploy em 2026-08-18 (2×) e de
novo em 2026-08-19, por falta de baseline de host: swap saturado, OOM, apps sem limite de memória.
Ninguém enxerga isso até travar. O recon de 2026-08-19 confirmou o quadro: 13 dos 24 apps rodam com
limits_memory:"0" (sem teto) e o /swap.img primário está 99,99% cheio. O auth é o SPOF do
ecossistema e já roda como container no mesmo daemon Docker do Coolify — pode ler o host de dentro.
Precisávamos expor métrica de saúde (por app e do host) para a Central diagnosticar antes de travar, sem introduzir um daemon que a VM (que já congela) tenha de pagar, e sem dar ao serviço acesso perigoso ao host.
Decisão¶
Dois endpoints REST admin read-only, sob demanda, na feature nova monitoramento:
GET /api/admin/monitor/apps (grade de apps: limites + RAM/CPU viva, limits_memory=0 destacável) e
GET /api/admin/monitor/host (RAM, swap, disco /, build-cache). Nenhuma ação destrutiva —
Fase 1 é 100% leitura (prune/restart/limpeza ficam para uma Fase 2 com whitelist + confirmação +
auditoria, desenhada à parte).
Como lê, com segurança (o socket do Docker é equivalente a root no host):
- Socket só via
docker-socket-proxycom allowlist mínima liberando só/containers/json,/containers/{id}/statse/system/df(POST=0,EXEC=0). Nunca o socket cru montado noauth. - Mount do host read-only de superfície mínima:
/proc → /host/proc(mem/swap) e um arquivo público (/etc/os-release → /host/probe) para ostatvfsdo disco/. Não se monta/inteiro (evita expor segredos de outros apps em leitura). - Endpoints atrás do filtro admin já existente (
@ServerFilter("/api/admin/**"), Firebasexadm_adminou shared secret). Timeout curto por chamada ao socket (~3 s) — o monitor não pode estressar a VM que observa.
Correção/Evolução 2026-09-11: o proxy implantado é o
wollomatic/socket-proxy(1.13.1, pinado por digest), não otecnativa/docker-socket-proxyque as flags acima (POST=0/EXEC=0) pressupõem. No tecnativa,CONTAINERS=1é regra por prefixo (^(/v[\d\.]+)?/containers) e libera todo GET sob/containers—/containers/{id}/jsonexpõe o env de todos os containers (inclusiveAUTH_PRIVATE_KEY), além dearchive/export/logs—, então a allowlist mínima desta decisão não era realizável nele. O wollomatic casa regex ancorada por rota e libera só as 3 rotas GET; o resto dá 403 e método ≠ GET dá 405 (verificado em produção). O/system/dfpassou a ser pedido com?type=build-cache(sem otypeo daemon percorre todos os volumes do host a cada chamada). O probe do disco virou um diretório vazio dedicado (/srv/xadm-probe,0555 root:root) no lugar do/etc/os-release, e o serviço hoje é ocentral-backend(0020). A decisão — socket só via proxy com allowlist mínima, mount de superfície mínima — segue de pé; o provisionamento está no runbook.Correção (2026-09-15): o mount do host não é read-only. O Directory Mount do Coolify é bind rw — a API do Coolify não tem mount read-only —, e a proteção é a pré-condição da casa (coolify, Criar uma aplicação): o container roda não-root (
USER app, uid 1001, noDockerfile.native) e só lê. Em container root, o bind do/procdo host não herdaria o read-only que o Docker aplica a/proc/syse/proc/sysrq-trigger. Os endpoints seguem só de leitura, e a superfície mínima (/proce o probe, nunca/inteiro) fica.
Degradação parcial por fonte: cada resposta é 200 mesmo com fonte fora; a fonte que falha
aparece em fontes: {..:"erro"} + erros: {code, msg} e seu dado vem null — nunca engolida em WARN
(feedback §6). Contêiner lento degrada por-item (aquele app fica com vivos null), não derruba o
endpoint.
Reuso: o CoolifyClient migrou de centralapps para um slice dedicado coolify (integração
externa, irmão das features), agora consumido por centralapps (broker) e monitoramento (grade). O
join app↔container é por coolify.name ⊇ uuid do app (a API do Coolify retorna id: null, então
o label numérico não serve); um app compose (stacks ps.*) mapeia N containers → soma o RAM vivo.
Alternativas preteridas¶
- Coolify Sentinel — sobe agente que polla todo container em intervalo curto. Custo de recurso contínuo que a VM que já congela não deve pagar. Métrica sob demanda basta.
- Socket cru montado no
auth— comprometimento total de produção num bug/invasão. Proxy com allowlist é inegociável. - Montar
/inteiro read-only — simples (1 mount, todas as métricas), mas expõe todos os arquivos do host em leitura. O par/proc+ probe de 1 arquivo dá as mesmas métricas com superfície mínima. - 2ª chamada ao Coolify (
/deployments) para deploy-status — +latência/+fonte/+carga. Deferido: a Fase 1 usa só o queGET /applicationsjá entrega (statustraz estado+health de graça).
Deferido (Regra Nº 3)¶
- kills do earlyoom — exigem o journal do host (journald binário → mount + parse caros), ROI baixo perto de mem/swap. Fase 2.
- Swap por-device — o
/proc/swapscapta a saturação do device primário; a Fase 1 reporta o agregado (mem/swap domeminfo). Refino barato futuro. ultima_deploy(sucesso/quando) — inexistente no/applications; a grade usalast_online_atcomo proxy de atividade.
Consequências¶
- A Central passa a ver, sob demanda, os
limits_memory=0e o swap saturado antes do próximo freeze — o valor direto da feature. - Novo slice
coolifyno grafo de arquitetura (ArchUnit):centralapps→coolify,monitoramento→coolify, acíclico. - Provisionamento de infra novo no Coolify (proxy + mounts) — ver runbook.
- O contrato JSON dos 2 endpoints é consumido pela tela do
central-ui(que roda o próprio fluxo).
0020 — Rename auth → central-backend (identidade interna) mantendo auth.xadm.biz como protocolo¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-19 · Decidido em: 2026-08-19
Contexto¶
O serviço nasceu como auth-service (domínio auth.xadm.biz), mas cresceu muito além de autenticação:
é o broker build-time da Central de Apps, o monitor de infra do Coolify e o backend da Central.
O nome auth ficou estreito. A versão native já é servida em central-backend.xadm.biz. Decidiu-se
renomear a identidade do serviço para central-backend — sem quebrar o que o PowerSync valida.
O risco: auth.xadm.biz não é só um domínio — é o host do JWKS que cada instância PowerSync
busca (jwks_uri) e o iss dos JWT que ela valida. Movê-lo exige reconfigurar o PowerSync de
todos os clientes em lockstep, senão todo o sync para.
Decisão¶
Separar identidade INTERNA de identidade de PROTOCOLO.
- Interna →
central-backend: repo, package Javabr.com.xadm.integracao.central(era.auth), prefixo de configcentral.*(eraauth.*),CentralSettings(eraAuthSettings),micronaut.application.name,app.json(slug/app_id/production_url), docs (README, site_url). - Protocolo → permanece
auth.xadm.biz: oissdo JWT e o/.well-known/jwks.jsonseguem emauth.xadm.biz(defaultsissuer=https://auth.xadm.biz,kid=auth-1inalterados). O serviço passa a ser servido também emcentral-backend.xadm.biz. Migrar o issuer/JWKS é um passo coordenado à parte (repointarjwks_uri+issuerem cada cliente PowerSync — ver runbook do PowerSync). - Env vars → dual-read
CENTRAL_*sobrepõeAUTH_*: oapplication.ymllêAUTH_*(o que o Coolify tem hoje); a varCENTRAL_*, quando definida, sobrepõe via relaxed binding do Micronaut (env > yml). O Coolify migraAUTH_*→CENTRAL_*sem janela de quebra. - Banco
db_authpermanece: nome interno, invisível externamente; renomear seria migração de dados sem ganho.
Alternativas preteridas¶
- Mudar o issuer/JWKS para
central-backend.xadm.bizagora: quebraria o PowerSync de todos os clientes sem uma janela de migração coordenada. Desacoplar é mais seguro. - Rename hard das env vars (
AUTH_*→CENTRAL_*sem fallback): exigiria trocar as 21 vars no Coolify no mesmo deploy — quebra se desalinhar. O dual-read remove a janela de risco. - Renomear
db_auth: migração de dados de risco por ganho cosmético nulo.
Consequências e gotchas (para o próximo rename)¶
- Placeholder aninhado do Micronaut é traiçoeiro.
${CENTRAL_X:${AUTH_X:}}com default interno vazio não colapsa para""nesta versão — resolve para um literal/lixo, que quebrou o carregamento da chave RSA (JwtService→ Base64 "Input byte[] should at least have 2 bytes"). A forma robusta de dual-read é single-level${AUTH_X:default}+ deixar oCENTRAL_X(env) sobrepor via relaxed binding — não aninhar placeholders. - String de config-key não é FQN de package. Um
seddebr.com.xadm.integracao.auth→.centralnão pega@Requires(property = "auth.firebase-project-id")nem@Value("${auth.integrator.token}"). Essas chaves de config precisam de rename à parte — um@Requireserrado deixa um bean condicional ligar quando não devia (aqui: oFirebaseTokenValidatorServiceligava comproject-idvazio e explodia no@PostConstruct). app_idmudou deauth→central-backend: se houver linha provisionada comapp_id="auth"nodb_auth(catálogo), o/xadm-setupcom o novo id cria/atualizacentral-backende a antiga fica órfã — reconciliar/re-provisionar no primeiro setup pós-rename.
0021 — Adoção do control-plane de deploy com resolução ancorada na imagem¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-30 · Decidido em: 2026-08-30
Contexto¶
A casa ratificou o control-plane de deploy no central-backend
(ADR central 0026 + constituição §9): o CI
dispara o deploy por API M2M, o central resolve app → recurso Coolify por um mapa auto-curável e chama a
API do Coolify — matando a ponte SSH + o allow-list órfão. Esta RD registra a adoção local (Fase 2, a
implementação) — não re-litiga a decisão (é da casa; o o quê e o porquê duráveis vivem na 0026 e em
infraestrutura/deploy.md, §1.9).
Agravante local: a decisão 0020 renomeou auth →
central-backend no app.json, mas o catálogo apps seguiu com app_id="auth" — nada no catálogo
(app_id, slug, production_url=auth.xadm.biz) casa com o app.json (central-backend). Resolver deploy
por app_id quebraria no rename, e o Gustavo exigiu que a resolução se auto-adapte a rebrand (o foco
do app muda com o tempo).
Decisão¶
A resolução de deploy ancora no SLUG DE REGISTRY DA IMAGEM (docker_registry_image_name), não no
app_id. O que o CI builda (fonte.xadm.biz/xadm/<slug>:<target>-amd64) e o Coolify puxa é o mesmo slug —
e esse slug espelha o app.json corrente, imune ao catálogo stale. Rebrand futuro flui sozinho.
POST /api/ci/deploy{app_id, target, image, instance?}—imageobrigatório (a âncora);app_idé rótulo/auditoria. Filtro próprio@ServerFilter("/api/ci/**")validaCENTRAL_DEPLOY_TOKEN(molde doSetupAuthFilter, não oAdminAuthFiltertudo-ou-nada).V10 app_deploy_targets(registry_slug, target, instance, coolify_uuid, fqdn), unique(registry_slug, target, coalesce(instance,'')), sem FK aapps(desacopla do catálogo stale) +deploy_audit(rastro por recurso;token_ref=sha256:+12hex, nunca cru).- Reconcile-from-Coolify ancorado na imagem: deriva
registry_slug(image name),target(tag),instancedo FQDN (int.<cli>.xadm.biz→<cli>; os 6 integradores compartilham o slug, só o fqdn os separa). Cadência on-boot + on-demand + on-miss com debounce. Auto-curável (blue-green/rebrand re-ancoram sozinhos). GET /api/ci/deploy/{deployment_uuid}— status degradável. ~~Guarda bootstrap: deploy do próprio central via o endpoint é recusado~~ → self-deploy do central agora é PERMITIDO (0022): a guardaself_deploy_refusedfoi removida (a "API direta" era inexequível do runner GitHub). O central se deploya pelo próprio/api/ci/deploy.
Alternativas descartadas¶
- Chavear no
apps.app_id— stale após 0020, quebra no rebrand (o oposto do exigido). - Casar por
fqdn/production_url— também divergiu no catálogo (auth.xadm.biz); menos estável que o slug da imagem. - Rename manual do catálogo (
auth→central-backend) como pré-requisito — não-adaptativo, repete no próximo rebrand. Fica como higiene separada, não-bloqueante.
Consequências¶
imagevira âncora de resolução, não só auditoria como a 0026/deploy.md(0.37.3) dizem — pendência de clarificar a doc central + patch bump (cross-repo).- Convenção de status HTTP dos endpoints M2M (sem-recurso
404, tudo-falhou502, parcial/ok200) decidida por analogia com o broker de provisão — candidato a normatizar na casa. (Oself-deploy 409saiu — revertido pela 0022.) - Higiene do catálogo
apps(app_id="auth"→central-backend, fechando o 0020) fica como follow-up não-bloqueante (o deploy resolve por slug/imagem). - Reuso:
CoolifyClient(deploy()+listApplicationscom image),SetupAuthFilter(molde), Flyway.
0022 — Self-deploy do central pelo próprio /api/ci/deploy¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-31 · Decidido em: 2026-08-31
Contexto¶
A 0021 (adoção do control-plane, ADR central
0026) fechou o deploy da frota pelo
POST /api/ci/deploy, mas recusava o deploy do próprio central (self_deploy_refused, 409) — a
"exceção de bootstrap": o central deployaria a si mesmo direto na API do Coolify a partir do seu
build-deploy.yml, sem passar pelo endpoint, "pra não depender de si mesmo no ar".
Ao migrar o central pra CI 100% GitHub (ADR central 0027, pipeline.yml), essa exceção não se
sustenta: a API /api/v1 do Coolify é restrita ao IP da casa, e o runner do GitHub tem IP
arbitrário → o build-deploy.yml não alcança a API direta; a ponte SSH que contornava isso foi
removida (0026). A cláusula "deploya direto na API" ficou inexequível.
Decisão¶
O central se deploya pelo próprio /api/ci/deploy, como qualquer app da frota. A guarda
self_deploy_refused é removida.
- O job
deploydopipeline.ymldo central fazPOST /api/ci/deploy {target, image}ao próprio central (image = fonte.xadm.biz/xadm/central-backend:<target>-amd64, a âncora de resolução — 0021), byte-idêntico ao job da frota. - O central-atual (no ar, dentro da casa) recebe o POST, resolve o próprio recurso Coolify (a
linha entra no
app_deploy_targetspelo reconcile on-boot — oDeployReconcileServicenão tem self-skip) e chama a API do Coolify localmente (IP da casa — sem o bloqueio do runner). O Coolify faz rolling deploy health-gated: a imagem nova sobe, passa no health → substitui; falha → a anterior segue no ar. - Não há auto-referência travante: o central-atual está no ar no disparo, e a chamada ao Coolify
enfileira e retorna antes do swap do container. O
imageé a âncora (0021),app_idnão resolve.
Alternativas descartadas¶
- Manter "deploya direto na API do Coolify" (0021/0026) — inexequível do runner GitHub (API IP-restrita), e a ponte SSH que a viabilizava foi removida.
- Webhook de deploy do Coolify por-alvo (secret no repo) — resolve o IP, mas adiciona um mecanismo e
secrets novos onde o
/api/ci/deployjá existe e alcança o Coolify de dentro da casa. Preterido — mais superfície por zero ganho. - Coolify auto-pull no push — menos controle do momento, sem auditoria; fica como reserva.
Consequências¶
- Reverte a guarda
self_deploy_refuseddoDeployService(ocentral.self.slugsome — era usado só nela). O caso de teste do 409 vira "self-deploy resolve e enfileira". - Break-glass: o central fora do ar não se autodeploya (não há quem receba o POST) → deploy manual pela UI/API do Coolify (de dentro da casa). É cenário de incidente, no runbook — o caminho normal (central-atual no ar) cobre todo release.
- Emenda à 0021 (a §Decisão "guarda bootstrap" e a §Consequências "self-deploy 409" ficam corrigidas
in-place apontando pra cá) e handoff feedback à ADR central 0026 (que ainda diz "sem
auto-referência" / "API direta" — as duas quebradas; a casa deve inverter: self-deploy pelo
/api/ci/deploy+ break-glass). - Sem migration nova, sem novo endpoint — só a remoção da guarda + o job
deploydopipeline.yml.
0023 — Login social: modelo status×role e auto-governança por app¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-02 · Decidido em: 2026-09-02
Status: aceita · Data: 2026-09-02 · Contexto: habilitar Sign-in-with-Google/Apple
(Firebase) em apps PowerSync da casa (1º consumidor: bi-comercial/Onpetro), com acesso pendente,
papel por app e gestão do acesso pela própria empresa. Linhagem .ia/015-* (prompt→spec→plan).
Decisão¶
1. Dois eixos: status (lifecycle) × role (papel), não uma coluna só¶
oauth_access_requests.status deixou de misturar acesso e papel. Agora:
status∈{PENDENTE, REJEITADO, ATIVO, INATIVO}— sóATIVOemite JWT; os outros 3 → 403 + tela de espera.INATIVO= soft-revoke (foi ATIVO, perdeu acesso).role(coluna nova, nullable) — papel data-driven por app, emitido verbatim no claimroledo JWT. É o valor que a sync rule do PowerSync consome para filtrar o bucket por papel — a segurança de dado mora aí, não na UI.
Migração V11 (prod tinha 0 linhas → schema-puro, risco zero): mapeou o modelo antigo
ADMIN→ATIVO+role=ADMIN, USER→ATIVO+role=DIRETORIA, SPAM→REJEITADO, PENDENTE→PENDENTE.
Diferença ADMIN×DIRETORIA: ambos veem tudo; só ADMIN administra usuários.
Preterido: reanimar a tabela ociosa oauth_access_grants como "acesso ativo". Recusado — o par
status+role numa tabela (com INATIVO=revogação) expressa os dois eixos sem 2ª tabela e sem 2
queries no login. oauth_access_grants foi dropada (V11); classes Java e teste saíram no mesmo PR.
2. Catálogo de papéis = tabela app_roles, semeada como dado (não migration)¶
app_roles(app_id→apps, role, label) (V12, criada vazia). Cresce sem deploy via CRUD admin
(/api/admin/apps/{app}/roles). role uppercase (machine-code). O approve valida contra o catálogo
(422 role_not_in_catalog).
Preterido: seed na migration (colaria metadado de app numa migration global; risco de FK) e string livre (typo → bucket errado silencioso). Migration é schema, não dado — o catálogo de cada app é semeado por runbook/CRUD antes do 1º approve (runbook).
3. @xadm no client-login: super-admin implícito, sem gravar¶
@xadm que faz client-login recebe token ATIVO+role=ADMIN (aud=cliente, claims oauth),
sem criar linha na fila. O firebaseAdminLogin (aud=xadm, para o central-ui) continua separado —
o token dele é inútil para o app (aud errado), por isso o client-login precisa do seu próprio caminho
@xadm. (Antes o client-login rejeitava @xadm.)
4. Auto-governança do tenant: superfície /api/apps/** (não uma nova /api/app/**)¶
O usuário role=ADMIN de um app gere os próprios usuários (listar pAbast∪oauth, aprovar, conceder
papel, ativar/inativar). Reusa o @ServerFilter("/api/apps/**") + AppUserAuthFilter existentes
(já validam o JWT de app e rejeitam token sem oauth_app_id — pAbast/anônimo/xadm → 403); o gate
role=ADMIN é por-endpoint (o mesmo prefixo serve os corretores, que são qualquer app-user).
Escopo sempre pelo claim (oauth_cliente_id, oauth_app_id) — nunca body/path (tenancy pelo claim).
- Escalonamento total do tenant: o ADMIN concede qualquer papel do catálogo, inclusive
ADMIN/DIRETORIA— modelo SaaS org-admin; a empresa cria o 2º admin sem depender de xadm. xadm é meta-admin acima (via/api/admin/**). - Guard de auto-lockout: recusa (
409 last_admin) a operação que zeraria os ADMIN ativos do par.
Preterido: criar /api/app/** novo (duplicaria o prefixo /api/apps/** da casa) e pôr role=ADMIN
no filtro global (quebraria os corretores app-user). Decisão do pivô confirmada com o dev na
implementação.
5. Notificação de novo pendente via Resend (fail-open)¶
Ao criar um PENDENTE novo (1ª tentativa social num app; UNIQUE garante 1×), o backend
notifica os aprovadores por email (Resend, padrão da casa — RESEND_API_KEY/RESEND_REMETENTE).
Destinatários = env AUTH_OAUTH_NOTIFY_EMAILS ∪ ADMINs ativos do (cliente, app). Fail-open: sem
chave é no-op; falha de envio nunca falha o login (computação de destinatários síncrona, envio HTTP
assíncrono com erro capturado/logado). Cliente HTTP = java.net.http.HttpClient (molde da casa; não
OkHttp nem @Client — evita metadata no native-image, decisão 0018).
Consequências¶
- O JWT social carrega
oauth_cliente_id,oauth_app_id,role(verbatim) — contrato consumido por apps + sync rules. Ver api-rest e modelagem. - Enforcement de papel diverge por camada de dados: PowerSync (bi-comercial) usa sync rule pela
claim
role; um app Firestore (ex.: precos, futuro) precisaria do role como custom claim do Firebase — fora de escopo agora. - Precondição operacional:
appsdo app catalogado +app_rolessemeado antes do 1º approve.
0024 — Acesso unificado do usuário ao app (pAbast ∪ oauth), fail-closed¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-02 · Decidido em: 2026-09-02
Status: aceita · Data: 2026-09-02 · Contexto: o 0023
deu role (claim no JWT, gate da sync rule do PowerSync) só ao login social (oauth). Mas os apps
PowerSync (bi-comercial/Onpetro) são acessados também por staff pAbast (login ERP), cujo token
não tinha role → com a sync rule filtrando por auth.parameter('role'), todo usuário pAbast zeraria.
Linhagem .ia/016-*.
Decisão¶
1. Uma tabela de acesso para os dois tipos de login — app_user_access¶
A antiga oauth_access_requests evoluiu para app_user_access (migração V13): o acesso de
qualquer usuário ao app — subject_type ∈ {oauth, pabast} — numa tabela só, carregando status
(lifecycle) + role (papel). subject_key = firebase_uid (oauth) | id/ChaveUsu (pabast). email
virou nullable (pАbast não tem). Prod tinha 0 linhas → migração quase-schema.
Preterido: tabela pAbast dedicada (menos blast no 0023) — recusada em favor do modelo unificado
(um só lugar para a tela de Usuários, a notificação, o auto-lockout e o gate). Custo aceito: refactor
de ~6 classes do 0023 (OauthAccessRequest→AppUserAccess), com o contrato observável preservado.
2. pAbast vira "app-user" — app_id opcional no login¶
POST /api/auth/pabast/login aceita app_id opcional. Com ele o token carrega oauth_app_id/
oauth_cliente_id + role (de app_user_access(pabast, subject_key=id, cliente, app)),
simétrico com o oauth — e o pAbast passa a poder usar /api/apps/**. Sem app_id → token legado
(scope=full, sem role/app): não-quebra os consumidores atuais (vantroba etc.).
Chave canônica do usuário pAbast = id/ChaveUsu (não id_usuario): pabast_senhas não tem UNIQUE
em (cliente, id_usuario) — o mesmo id_usuario pode ter várias linhas (0008) — e o id é o mesmo
campo já usado como sub do token. Colisão de pessoa fica impossível.
3. Fail-closed + resolução por admin¶
Sem papel → sem claim role → a sync rule zera o bucket (login pАbast segue 200: a credencial
ERP é válida, papel ausente ≠ erro). O app mostra "Peça ao Admin definir seu acesso" e consome
GET /api/apps/admins-contato — os ADMIN ativos do (cliente,app) do claim ([{nome, email}],
email null quando o admin é pАbast; acessível a qualquer app-user, mesmo sem papel). Lista vazia
→ o cliente mostra "contate o suporte xadm". O 1º ADMIN é semeado por xadm (bootstrap) — não há
auto-bootstrap pelo tenant.
Preterido: default full-access (staff sem papel vê tudo) — recusado: o usuário escolheu fail-closed (papel ausente = sem dado, não "vê tudo").
4. Atribuição unificada — self-service (app-ADMIN) + paridade xadm¶
O app-ADMIN atribui papel a pАbast e oauth pela tela unificada de Usuários (/api/apps/usuarios,
pАbast LEFT JOIN app_user_access dedup por id): POST /api/apps/usuarios/pabast/{id}/role. O xadm
espelha em /api/admin/** (sem escopo no claim → cliente no path, app_id no body):
POST .../pabast/users/{clienteId}/{id}/role {app_id, role} (bootstrap), leitura
GET .../pabast/users?cliente=&app= (com app_role/app_status) e revoke
POST .../pabast/users/{clienteId}/{id}/access-status {app_id, status∈{ATIVO,INATIVO}} — sem
409 last_admin (o xadm é meta-admin/saída de emergência; o guard de auto-lockout fica só no
self-service app-ADMIN).
5. Contrato preservado para o central-ui¶
O response admin oauth continua emitindo a chave JSON firebase_uid (= subject_key das linhas
oauth) e email não-null — o painel faz parse hard-non-null (handoff central-ui, travado em teste).
Consequências¶
- Precede o gating por
rolena sync rule do PowerSync (bi-comercial) — sequenciamento obrigatório: central-backend (este contrato) → bi-comercial-xls (segmento+ backfill) → powersync (sync rule, por último; coluna/role ausente derruba a rule). - Fora deste repo (follow-ups declarados): bi-comercial (login
app_id, tela "peça ao admin"), central-ui (ação atribuir/revogar papel pАbast nopabast_users_screen), powersync (powersync.yaml).
0025 — Catálogo de papéis declarado pelo app, ingerido pelo central¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-04 · Decidido em: 2026-09-04
Contexto¶
O role de um usuário é o eixo de autorização do ecossistema: vai verbatim no claim role do JWT e
é o que a sync rule do PowerSync consome para filtrar dado. Os valores aceitos por app vivem em
app_roles (V12) e alimentam o picker de quem aprova um acesso — o
central recusa atribuir papel fora do catálogo (422 role_not_in_catalog, decisão
0024).
Até aqui esse catálogo era curado à mão, via POST /api/admin/apps/{app}/roles (runbook
semear o catálogo). Quem sabe quais papéis existem é o
app — é o código dele que faz o gate por papel. O central sabia por cópia manual, e cópia manual
drifta: no e2e-staging o bi-comercial já gateava DIRETORIA e o papel não aparecia no picker do
central, então ninguém conseguia conceder um acesso que o app já sabia respeitar. O sintoma é
silencioso — não há erro, só uma opção que falta numa tela.
Decisão¶
O app declara; o central ingere. O app publica os papéis que aceita num arquivo estático:
GET <base>/roles-catalog.json
{"app_id": "bi-comercial",
"roles": [{"role": "ADMIN", "label": "Administrador"},
{"role": "DIRETORIA", "label": "Diretoria"}]}
e o central puxa e faz upsert em app_roles por
POST /api/admin/apps/{appId}/roles/sync (corpo opcional {"base_url": "..."}, que sobrepõe a
apps.production_url para ambientes onde ela não é alcançável — e2e local).
Três propriedades decidem o desenho:
- Upsert, nunca delete. Papel que sumiu da declaração fica no catálogo. Apagá-lo deixaria
órfãos os
app_user_accessque já referenciam aquelerole— o grant é escopado a(cliente, app, subject, role). Remover é ação manual e deliberada (DELETE /api/admin/apps/{app}/roles/{role}), depois de tratar os grants. roleé machine-code, normalizado para MAIÚSCULA na ingestão;labelé só o rótulo humano do picker e pode mudar sem consequência. Entrada semroleé ignorada.- Pull, não push. O central puxa quando mandam sincronizar; o app não precisa de credencial nem saber que o central existe. O arquivo é público — nome de papel não é segredo.
O endpoint é admin-gated (AdminAuthFilter: JWT xadm_admin ou o shared secret do integrador).
Como o base_url é entrada do chamador, o cliente HTTP recusa esquema fora de http(s) e URL sem
host — sem isso o sync seria um primitivo de chamada de saída arbitrária a partir do servidor.
Alternativas consideradas¶
- Manter a curadoria manual. Rejeitada: é exatamente o drift que motivou a decisão, e ele é silencioso (nada falha; uma opção só não aparece).
- O app faz
POSTdos papéis no central (push). Rejeitada: exigiria dar credencial de escrita a cada app e um passo de deploy em cada app. O pull mantém o app burro sobre o central. - Deduzir os papéis dos grants existentes. Rejeitada: inverteria a fonte da verdade — o catálogo passaria a refletir o que já foi concedido, e um papel novo nunca poderia ser concedido a ninguém.
- Sincronizar automaticamente (agendado). Deferido: o gatilho manual basta enquanto papel novo é
evento de release. Vale reavaliar se a frota crescer — o custo seria um
@Scheduledchamando o mesmo serviço.
Consequências¶
- Papel novo no app vira: publicar o
roles-catalog.jsonno deploy do app + umPOST .../roles/sync. O runbook semear o catálogo passa a ter o sync como caminho preferido e oPOSTunitário como saída manual. - O central ganha uma dependência de saída para os apps (era só o inverso). A falha é isolada e
explícita:
502 roles_catalog_unreachablequando o app não responde,422quando obase_urlé inválido ou o app declara um papel maior que a coluna. - O arquivo vira contrato de plataforma: um app da casa que participa do modelo de papéis deve servi-lo. Candidato a virar norma no repo central (§6).
0026 — Apps compose (build-from-git) no control-plane, ancorados no slug do git repo¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-05 · Decidido em: 2026-09-05
Contexto¶
A 0021 (ADR central
0026) ancorou o control-plane de deploy
na imagem de registry: o reconcile só olha recursos Coolify com build_pack=dockerimage, deriva
registry_slug do docker_registry_image_name e target do prefixo da tag (<target>-amd64), e o
POST /api/ci/deploy exige image como âncora obrigatória.
Isso deixa de fora toda uma classe de app da casa: os recursos Coolify com
build_pack=dockercompose, que o Coolify builda a partir do git (os stacks PowerSync —
onpetro-powersync, maxsul-powersync, … — mongo + powersync num compose só). Eles não têm
imagem de registry: docker_registry_image_name e _tag vêm nulos, então o reconcile os pula e
nenhuma linha entra no app_deploy_targets. Sem linha, o /api/ci/deploy devolve 404
no_deploy_target — e o GitHub Actions desses repos fica sem caminho de deploy: ou faz auto-pull no
Coolify (sem gate, sem auditoria), ou volta pra ponte SSH que a 0021 matou.
O deploy em si é o mesmo: POST /deploy?uuid=<coolify_uuid> na API do Coolify, que rebuilda o
compose a partir do git. Só falta a âncora — o que identifica o recurso sem depender do
app_id do catálogo (que a 0021 recusa de propósito, por ser rebrandável).
Decisão¶
Recurso compose entra no control-plane ancorado no SLUG DO GIT REPO, com target='compose';
o endpoint ganha um segundo modo de âncora (app_id) para quem não tem imagem.
- Reconcile (
DeployReconcileService): além dedockerimage, tratadockercompose—registry_slug= último segmento dogit_repositorysem.gite sem credencial embutida (https://user:pass@fonte.xadm.biz/xadm/onpetro-powersync.git→onpetro-powersync),targetfixo'compose',instancedo FQDN como sempre. Compose sem git parseável (database ou serviço criado na UI) é pulado — não inventa linha. Build packs fora de{dockerimage, dockercompose}(nixpacks, static) seguem ignorados, agora explicitamente. - Endpoint (
POST /api/ci/deploy):imagedeixa de ser obrigatório. Comimage→ tudo como antes (a âncora é a imagem;app_idsegue rótulo de auditoria). Semimage→app_idé a âncora (registry_slug) etargetpassa a ser obrigatório. Novos códigos de erro:missing_anchor(nemimagenemapp_id) emissing_target(semimagee semtarget), ambos400. - Schema (V15): o CHECK de
app_deploy_targets.targetpassa a{jar, native, web, compose}. - Contrato pro CI do app compose:
POST /api/ci/deploy {"app_id":"<slug-do-repo>","target":"compose"}com oCENTRAL_DEPLOY_TOKEN. Oapp_idaqui é o nome do repo git, não oapp_iddo catálogo — eles coincidem por convenção, mas quem manda é o repo.
Alternativas descartadas¶
- Continuar exigindo
imagee publicar uma imagem falsa pros stacks compose só pra caber na âncora existente — build inútil, registry poluído, e a imagem nunca seria puxada (o Coolify builda do git). - Auto-pull do Coolify no push (webhook nativo) — é justo o que a 0021 recusou pra frota: sem
gate do CI, sem auditoria (
deploy_audit), sem controle de quando. Aceitá-lo só pros compose criaria dois modelos de deploy na casa — o preço da exceção é maior que o do segundo modo de âncora. - Ancorar compose no
app_iddo catálogoapps— reintroduz a dependência rebrandável que a 0021 removeu de propósito. O slug do git é tão estável quanto o slug da imagem, e igualmente observável no próprio recurso Coolify. - Endpoint separado (
/api/ci/deploy-compose) — duplicaria resolução, auditoria e on-miss por uma diferença que cabe num campo. Preterido.
Consequências¶
- O mapa
app_deploy_targetspassa a ter duas famílias de linha com a mesma chave lógica(registry_slug, target, instance): por imagem (jar|native|web) e por git (compose). Otargeté que as separa — nada mais muda na resolução, no on-miss, na auditoria nem noGETda frota. - Assunção que vale registrar: um repo git = um stack compose. Dois recursos compose do mesmo
repo, ambos com FQDN fora do padrão
int.<cli>.xadm.biz(→instanceNULL nos dois), colidem na UNIQUE e o segundo upsert sobrescreve ocoolify_uuiddo primeiro — um sumiria do mapa em silêncio. Hoje a convenção da casa é um repo por cliente (onpetro-powersync,maxsul-powersync, …), então o caso não existe; se um dia existir, o desempate é dar FQDNint.<cli>ao recurso (aíinstancesepara as linhas). target='compose'também é aceito no caminho por imagem (a validação detargeté única). Uma chamada{image: "…:jar-amd64", target: "compose"}passa a validação e cai em404na resolução — frouxo, mas inócuo; não vale um segundo conjunto deTARGETS.- Handoff §6 pra ADR central 0026, que ainda descreve a âncora como sendo a imagem e só ela: a
casa precisa da emenda "control-plane resolve compose pelo slug do git repo, com o segundo modo de
âncora
app_idno endpoint". - Sem endpoint novo, sem token novo, sem mecanismo novo — uma migration de CHECK, um ramo no
reconcile e um
ifna resolução.
0027 — Adota o smoke de produção pós-deploy e a identidade de build¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-09-08
Contexto¶
Esta não é uma decisão nova: é o registro local de que o central-backend adotou uma
norma da casa. O porquê está no ADR central
0031 — Smoke de produção pós-deploy
e na página engenharia/smoke-producao
(constituição §9, versão 1.3.0). Este documento existe para o rastro do quando e do como
neste repo — não re-litiga a escolha da casa.
O que a norma corrige é um ponto cego que o central-backend tem em dose dupla:
- O
POST /api/ci/deployé assíncrono — retorna sucesso e o pipeline fica verde antes de o container novo servir. "Deployado" sem nada ter verificado o resultado. - O app deploya a si mesmo por esse endpoint (0022). Um deploy ruim aqui não derruba só este app: derruba o control-plane que toda a frota usa para deployar. O incidente que escreveu a norma da URL flavor-agnóstica foi exatamente esse.
- O app tem dois deploys da mesma versão (jar e native, 0018).
Sem discriminador, os dois
/healtheram indistinguíveis.
Decisão¶
Adotar as quatro peças da norma, no mesmo PR.
-
XADM_COMMITatravessa o pipeline inteiro. O CI passa--build-arg XADM_COMMIT=<sha>nos dois builds;DockerfileeDockerfile.nativedeclaramARG/ENVtarde (depois doCOPY, no estágio final) — no topo, oARGinvalidaria o cache de tudo abaixo e onativeCompile(~13 min) rodaria de novo a cada commit.A env se chamava
SOURCE_COMMIT— e o nome estava erradoCorrigido em 2026-09-09, depois da v0.8.0.
SOURCE_COMMITé variável predefinida do Coolify: em recurso pull-only ele a injeta em runtime com o literalHEAD, por cima doENVda imagem. Este app foi onde o defeito apareceu — o build passou--build-arg SOURCE_COMMIT=81cbb619…, a imagem no registry carregava o sha, e produção respondia"commit":"HEAD". A camada 1 do smoke passou por liveness sem provar nada. Cura:XADM_COMMIT(namespace da casa) +xadm-comum-web0.9.0, que trataHEADcomo ausente. Norma: smoke-producao §6 e coolify §Carimbar o commit. -
xadm-comum-websobe para0.9.0(era0.8.0no corte original), que lê essa env e emitecommitno/health(e usa o mesmo sha comoreleasedo Sentry/GlitchTip quandoSENTRY_RELEASEnão está setada). O mesmo bump trazflavor(native|jvm), que responde "qual dos dois deploys está no ar". - Job
smokenopipeline.yml, depois dodeploy, rodando osmoke.pyda casa contra as rotas declaradas emdocs/app.json→smoke.routes. Reprovou → reverte para a tag imutável<imagem>:<sha anterior>-<target>, confirma a reversão e fica vermelho. CENTRAL_DEPLOY_URLdeixa de carregar flavor de build — passa ahttps://central-backend.xadm.biz/api/ci/deploy. Ver "Consequências".
Correção (2026-09-15): a reversão do item 3 não é automática. O
smoke.pypede ao control-plane a tag imutável, e oPOST /api/ci/deploysó aceita a tag móvel<target>-amd64— responde400 invalid_image. O smoke reprova e reporta o alvo; o operador reverte à mão (Deploy, Reversão). A norma da casa registra o rollback automático como pendente no control-plane.
Alternativas descartadas¶
- Seguir só com o
/healthcomo gate./healthverde é liveness, não "as rotas respondem": o JWKS pode estar vazio, o banco pode ter subido sem a chave, e o/healthdeste app é 200-fixo de propósito (não flapa por indicador JDBC — 0016). Liveness verde com JWKS quebrado derruba o login de toda a frota em silêncio. versaocomo discriminador de entrega. Não distingue: dois deploys da mesma versão (umworkflow_dispatchsem bump) servem a mesma string. Semcommit, o smoke não tem nem critério de identidade nem alvo de rollback.- Declarar a camada 3 (log-guarantee) agora. Declarar
smoke.glitchtipsem cadastrar oGLITCHTIP_API_TOKENreprova por desenho — é defeito de configuração, não aviso. O bloco fica de fora até o secret existir; é o self-skip previsto na norma, não omissão.
Consequências¶
- O
CENTRAL_DEPLOY_URLmudou de host. Eracentral-backend-jar.xadm.biz— flavor de build numa URL que outro sistema consome, exatamente o que a constituição §9 proíbe desde o incidente em que parar o jar numa promoção a native derrubou oPOST /api/ci/deployda frota (503). Pré-condição operacional:central-backend.xadm.biztem de rotear para um recurso no ar que sirva/api/ci/deploy. Enquanto não roteia, o deploy da frota inteira falha nocurl -fsS. - Rotas do smoke começam públicas.
/health,/.well-known/jwks.jsone/api/auth/jwks— as três que a frota e o PowerSync consomem sem credencial. Rotam2mentra quando houverSMOKE_M2M_TOKEN; rotasessionnão se aplica (este app rodamicronaut.security.enabled=false, postura do hub — 0016 — logo não há seam de sessão a expor). - A mesma lista serve dois gates: o e2e native afirma
≠404no binário antes do deploy; o smoke afirma o status esperado depois. - 404 autenticado passou a glitchar (efeito do bump da lib, 0.7.1). Rota morta batida por cliente com credencial vira evento no GlitchTip — é o sinal desejado, e a rede de runtime para a classe de defeito "AOT podou rota registrada" que motivou a mudança na lib.
0028 — Heartbeat do integrador-client, alerta de silêncio e CENTRAL_API_TOKEN¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10 · Decidido em: 2026-09-10
Decisão local do central-backend. Não confundir com o ADR central 0028 da casa (sync event-driven das docs), que o
application.ymltambém cita.
Contexto¶
O integrador-client é um jar Java 8 que cada cliente roda on-premise (Windows, Agendador de Tarefas)
— um app com N instalações, uma por cliente. Uma instalação que para (máquina desligada, jar
travado, .properties quebrado) ninguém vê: o write-back para de chegar e o sintoma aparece horas
ou dias depois, relatado pelo cliente. A casa também não sabe, sem acesso remoto, qual versão
roda em cada cliente nem quais fluxos estão ligados. E não há cadastro de instalação: o cliente
recebe o jar e começa a rodar.
Decisão¶
- Heartbeat em dois saltos, pelo integrador-server do cliente. O client manda o heartbeat
(versão, fluxos, hostname) no startup e a cada 60 min ao integrador-server do próprio cliente
(
int.<cliente>.xadm.biz, destino que o firewall já libera, com o Bearer que o write-back já usa). O integrador-server carimba ocliente_id(envCLIENTE_ID) e repassa ao central emPOST /api/integrador/heartbeat. Carimbar no integrador-server cumpre a norma de tenant (identidade do contexto autenticado, nunca do corpo): cada instância é de um cliente só. - Contrato congelado entre os 4 repos, com amostras canônicas copiadas como fixture de teste em quem produz e em quem consome. A casa durável de K2–K4 (o que o central expõe) é o OpenAPI publicado e o contrato da API REST; a de K1 (client → integrador-server) é do integrador-server.
- Token M2M próprio,
CENTRAL_API_TOKEN, com filtro próprio (IntegradorAuthFilter) que só abre/api/integrador/**. Nome pelo destino (<DESTINO>_API_TOKEN), um valor compartilhado por todos os integrador-servers. Não reusa oAUTH_INTEGRATOR_TOKEN(sync de senhas pAbast), que abre todo o/api/admin/**. Emprod, token vazio recusa o boot (IntegradorTokenGuard). - Registro automático por upsert: uma linha por cliente em
integrador_instancias, criada no 1º heartbeat. Sem@Transactionale sem lock — a leitura prévia só escolhe o log ("registrado" ou "voltou").cliente_idfora declientes→422 cliente_desconhecido+WARN(misconfiguração do integrador-server), sem linha. - Silêncio medido em horas úteis: 07:00–22:00, seg–sex, sem feriado nacional, em
America/Sao_Paulo. Passou de 6h úteis, oSilencioJob(a cada 15 min) alerta uma vez por episódio. Carnaval e Corpus Christi (ponto facultativo) contam como feriado — menos alerta falso. Régua no código, sem property. - Eleição por claim condicional, não por lock:
UPDATE … SET alertado_em = :agora WHERE alertado_em IS NULL AND ultimo_heartbeat_em = :visto. Só a réplica que vira a linha alerta; um heartbeat que chegou entre a leitura e o claim faz o claim falhar (sem alerta falso). - Alerta = evento do GlitchTip do central com fingerprint por cliente
(
["integrador-client-silencioso", <cliente_id>], tagcliente), viaSentry.captureMessage, maisWARNno log — nuncaLOG.error, que oSentryAppenderduplicaria. Uma issue por cliente: o alerta padrão do GlitchTip notifica a cada evento, então reincidências notificam do mesmo jeito. - Um relógio só (
Clockinjetado,ClockFactory) carimba, reivindica e mede. O instante gravado é o do servidor, nunca o do client. - API admin sem paginação e com
DELETEidempotente.GET /api/admin/integradoresé lookup fixo pequeno (no máximo uma linha por cliente), a isenção de paginação da norma — mesmo precedente doGET /api/admin/clients. ODELETEresponde204com ou sem a linha: um404numa requisição com credencial vira ERROR no GlitchTip (regra daxadm-comum-web0.7.1+), e cada corrida benigna abriria issue. É o botão "Parar de acompanhar" da aba Deploy do central-ui.
Alternativas descartadas¶
- Client direto ao central. Exigiria liberar um destino novo no firewall de cada cliente e distribuir um token novo em cada instalação.
- Heartbeat pela fila do PowerSync. É canal de dado de negócio; misturar sinal de vida nele acopla a observabilidade à saúde do próprio canal que ela observa.
- Fallback do
cliente_idpara oCLIENTEdo integrador-server. OCLIENTEé nome de exibição (Maxsul,On Petro Trading…) e nunca passaria na regex; o fallback esconderia a misconfiguração. - Reusar o
AUTH_INTEGRATOR_TOKEN. Abriria todo o/api/admin/**a cada integrador-server. - Fingerprint por episódio (issue nova a cada silêncio). Não notifica mais do que por cliente e aumenta a exposição ao corte do smoke pós-deploy (issue nova reprova; reincidência só avisa).
pg_try_advisory_lockpara eleger a réplica. Mesmo efeito com lock de sessão e conexão presa; o claim condicional é idempotente por construção.- Outbox, retry ou histórico de heartbeats. O heartbeat é periódico — perder um é irrelevante, o próximo cobre. O estado atual basta para "está vivo?".
Consequências¶
CENTRAL_API_TOKENé obrigatório nos dois recursos do central (jar e native), como oCENTRAL_DEPLOY_TOKEN— sem ele o container não sobe. Ver o runbook de deploy e o runbook do heartbeat.- O bind do
String[]na@Querynativa (com@TypeDef(STRING_ARRAY)no parâmetro) é provado na JVM pelo teste; no binário native, só pelo passo 1 do smoke real depois da release. - O primeiro alerta da vida de um cliente dentro da janela do smoke pós-deploy pode reprovar um
deploy do central (o central não carimba
releaseno Sentry, então a camada 3 corta por tempo). OinitialDelay = 20mdo job tira a réplica nova dessa janela; o risco residual está aceito. - Regra de notificação do GlitchTip do projeto do central tem de ser por evento, não só "issue nova" — senão as reincidências não notificam. Ver provedor GlitchTip.
- Apagar não desliga: parar de acompanhar uma instalação cujo jar ainda roda só dura até o
próximo heartbeat. O desligamento de verdade é no client (
heartbeat.intervaloMinutos=0).
0029 — Adota o CI/CD 100% GitHub Actions (ADR central 0027)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-08-31
Decisão local do central-backend que aponta a decisão CENTRAL 0027 da casa. Os números não se correspondem: a local 0027 é o smoke de produção, e o ADR central 0029 é o catálogo de papéis.
Contexto¶
Este RD não decide nada novo: CI/CD dos apps em GitHub Actions, num pipeline.yml único por
repo, é norma da casa (ADR central 0027). O que faltava era o rastro local: quando o
central-backend migrou e o que veio amarrado.
Decisão¶
O central-backend passou a rodar o CI/CD no GitHub Actions em 2026-08-31 (commit 24e1807): o
.github/workflows/pipeline.yml único (gate, docs, build, release e deploy) substituiu o
build-deploy.yml.
O que veio junto, e por quê:
- O central se deploya pelo próprio control-plane. O job
deployfaz o mesmoPOST /api/ci/deployda frota contra este serviço — decisão local 0022. A API do Coolify não é alcançável do runner do GitHub, e a ponte SSH saiu com a migração. - A publicação de docs avisa o site central. O mesmo commit trouxe o
POST /api/ci/docs-published(ADR central 0028), que cutuca a sincronização do site central quando um app publica — fim do poll de 90 s do Garage.
Consequências¶
- O runner Forgejo deixa de fazer CI deste repo. O Forgejo segue como git origin e mirror: a fronteira da 0027 é "0% Forgejo no CI", não "0% Forgejo".
- O
pipeline.ymlé template rastreado da casa: muda por re-derivação (/xadm-docs) a partir do central, não por edição solta. - Este RD é ponteiro: divergência de mérito sobre o modelo de CI se resolve no ADR central 0027, não aqui.
0030 — Adota o native por padrão (ADR central 0033)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14 · Decidido em: 2026-09-14
Decisão local do central-backend que aponta a decisão CENTRAL 0033 da casa. Os números não se correspondem.
Contexto¶
Server Micronaut da casa é native por padrão, e jar só entra com motivo registrado — um bloqueio de
native numa decisão do app, ou host sem a arquitetura do binário (ADR central 0033, que substitui a
central 0022 citada na local 0018). O central-backend já servia
native, mas o pipeline.yml ainda buildava e deployava um par jar de canário. Faltava o rastro local
da adoção e a retirada do jar.
Decisão¶
Desde 2026-09-14 o build.targets do docs/app.json contém só native, e o pipeline.yml segue a
variante native do template: sem o job build_jar e sem o step de deploy jar. O Dockerfile JVM fica no
repo como par do Dockerfile.native, para build e diagnóstico locais.
Consequências¶
- Não há motivo de jar a registrar: nenhum bloqueio de native está aberto, e o e2e native cobre a superfície do binário.
- A saída de emergência é a tag imutável
<imagem>:<sha>-nativedo smoke, não um deploy jar. - O recurso jar do canário no Coolify deixa de receber imagem nova; desligá-lo é operação de infraestrutura, fora deste repo.
- Este RD é ponteiro: divergência de mérito sobre native × jar se resolve no ADR central 0033.
Alternativas consideradas¶
- Manter o jar por dispatch manual, como fallback: descartado. A 0033 exige motivo registrado para o jar, e a tag imutável do native já cobre o rollback.
0031 — Adota as libs da casa na versão corrente (ADR central 0034)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14 · Decidido em: 2026-09-14
Decisão local do central-backend que aponta a decisão CENTRAL 0034 da casa. Os números não se correspondem.
Contexto¶
O app declara a última versão publicada de cada módulo do xadm-commons que consome. Atrás da corrente
é pendência, e abaixo do piso a /xadm-release recusa a release (ADR central 0034, que substitui a
política de piso da central 0019). O central-backend consome a xadm-comum-web e a xadm-comum-teste,
e os comentários do build ainda citavam a regra antiga do piso.
Decisão¶
Desde 2026-09-14 o central-backend acompanha a corrente dos dois módulos: xadm-comum-web 0.10.0 e
xadm-comum-teste 0.4.0. O mesmo PR aplica as seções ### Migração de cada CHANGELOG: o DSN do
GlitchTip vem só do SENTRY_DSN, e o Postgres de teste passa ao singleton da xadm-comum-teste, com a base
IntegracaoComPostgres e as regras de fronteira de controller da própria lib no lugar das cópias locais.
Consequências¶
- Bumpar lib é migrar: a próxima
/xadm-releasesobe o que estiver atrás e aplica as### Migraçãoem ordem, removendo o código local que a lib passou a prover. - O pin explícito do ArchUnit no
build.gradle.ktsfica, mesmo com axadm-comum-testetrazendo o seu: a guarda de piso dopipeline.ymlsó enxerga a coordenada declarada no build. - Este RD é ponteiro: divergência de mérito sobre versão de lib se resolve no ADR central 0034.
Alternativas consideradas¶
- Ficar nas versões que o registro servia no dia (0.9.1 e 0.3.0) e bumpar depois: descartado. A migração do Postgres de teste já entrava neste PR, e a 0.4.0 é a versão que traz o singleton com tmpfs.
Correção 2026-09-14: a
xadm-comum-testesai como 0.4.0, não 0.3.1 — o número local anterior não foi publicado.
0032 — Rota de teste do GlitchTip sob o JWT xadm_admin¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14 · Decidido em: 2026-09-14
Contexto¶
A decisão 0011 abriu /test como superfície
pública e rate-limited para checar o pipe de erro em produção, inclusive o POST /test/glitchtip, que
gera um evento no GlitchTip. A norma da casa para Java/Micronaut pede a rota de teste de erro
autenticada (observabilidade):
aberta, qualquer um gera evento no projeto do app, e um evento desses durante um deploy pode reprovar a
camada do smoke que procura exceção nova.
Decisão¶
O POST /test/glitchtip exige o JWT interno com xadm_admin. Quem cobra é o AdminAuthFilter, que
passa a cobrir a rota por path (@ServerFilter({"/api/admin/**", "/test/glitchtip"})): sem token a
resposta é 401, e com token sem xadm_admin, 403 — ambos em problem+json. O GET /test segue público e
mostra o curl com Bearer no lugar do formulário.
O mecanismo é o filtro, e não o @Secured(IS_AUTHENTICATED) da receita da norma: o central não usa o
micronaut-security para autorização (decisões 0007 e
0015).
Consequências¶
- Esta decisão supera o item 4 da 0011 só para a rota do GlitchTip; o resto de
/testsegue público. - O shared secret do integrador, aceito pelo mesmo filtro, também abre a rota.
- A página
/testperde o botão: um formulário do navegador não manda o headerAuthorization.
Alternativas consideradas¶
- Manter a rota pública e registrar o desvio da norma: descartado. O rate limit por IP não impede que qualquer um gere evento no GlitchTip de produção.
0033 — Dois tokens de serviço para o central, um por escopo de rota¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14 · Decidido em: 2026-09-14
Contexto¶
A norma de segurança da casa nomeia o token pelo destino e pede um token por app chamado (Segurança, Segredos). O central aceita dois tokens de serviço de chamadores da integração:
CENTRAL_API_TOKEN, que todos os integrador-servers usam para/api/integrador/**(heartbeat do integrador-client, decisão 0028);- o shared secret do integrador (
CENTRAL_INTEGRATOR_TOKEN, lido também comoAUTH_INTEGRATOR_TOKENpelo fallback do rename), que abre/api/admin/**para o sync de senhas pAbast.
A 0028 separou os dois de propósito, e a norma nova torna a separação uma exceção a declarar.
Decisão¶
O central mantém os dois tokens, cada um preso ao seu filtro e ao seu escopo de rota. O
IntegradorAuthFilter aceita só o CENTRAL_API_TOKEN, e só em /api/integrador/**. O AdminAuthFilter
aceita o shared secret do integrador em /api/admin/** e na rota de teste do GlitchTip (decisão
0032). Aqui a unidade do token é o escopo de rota, não o app chamado.
Consequências¶
- O
CENTRAL_API_TOKENfica em todas as instalações de integrador-server. Com um token só, cada uma delas ganharia o CRUD admin do central. - A rotação tem dois alvos:
grep CENTRAL_API_TOKENnão acha o shared secret do integrador. Os dois estão na tabela de variáveis de ambiente do README. - Os dois nomes seguem o prefixo do destino (
CENTRAL_); oAUTH_*é só o fallback do rename (decisão 0020). - A norma de segurança da casa registra a exceção nominal, com dono (Segurança, Segredos); esta decisão guarda o porquê.
Alternativas consideradas¶
- Unificar no
CENTRAL_API_TOKEN: descartado. Daria/api/admin/**a todo integrador-server, o que a 0028 já tinha rejeitado, e exigiria mudança coordenada no integrador e nos envs do Coolify.
0034 — Refresh do JWT por token e teto absoluto de sessão¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-09-15
Contexto¶
Teste de papel e inativação no bi-comercial com usuário pAbast, em 2026-09-15: o usuário inativado seguiu com acesso, e a troca de papel (LUBRIFICANTE → COMERCIAL) não apareceu nem com F5. As duas coisas só refletiam com logout/login.
A causa foi medida no código. A sessão pAbast guarda a senha só em memória, e o app renovava o JWT
re-logando em /api/auth/pabast/login. Depois de um F5 a senha some e não há como renovar: o papel fica
congelado até o JWT vencer (AUTH_TOKEN_EXPIRATION_MINUTES, 24 h). O central não tem revogação no
servidor, e o PowerSync aceita o token até o exp.
A casa fixou o desenho na ADR central 0037 e no bullet de sessão da norma de Segurança. O prazo de revogação é o TTL do access token, de no máximo 60 min. O app renova trocando o token vigente, e a sessão tem teto absoluto de no máximo 7 dias. O contrato (rota, códigos, claims) é do central, e esta decisão o registra.
Decisão¶
POST /api/auth/refreshtroca um JWT de app-user ainda válido por um novo, com a mesma decisão do login (cliente ativo, método de auth, acesso e papel atuais) e sem re-provar a credencial. Só oAuthorization: Bearer, sem corpo; cliente e app saem do próprio token. Token semoauth_app_id(legadoscope=full, anônimo,xadm_admin) não tem via de refresh:403 not_app_user.- Claims novos nos tokens de app-user:
sub_type(pabast|oauth) diz quem é osub, eauth_time(epoch s) guarda o último login com credencial. O refresh copia oauth_time. - Token de antes do deploy, sem
sub_type, é classificado peloemail. Medido no código: o login pAbast emite sememaile os dois caminhos oauth sempre o emitem (vazio, se o Firebase não o deu). Semauth_time, a sessão conta doiat. - Teto absoluto de sessão:
AUTH_SESSION_MAX_HOURS, default 168 h (7 dias). É também o máximo da norma, e valor acima é cortado, como o TTL já é cortado em 1440 min. O teto conta doauth_time. Além dele, o refresh recusa com403 session_expired, e oexpemitido nunca passa do teto:exp = min(agora + TTL, auth_time + teto). Sem o corte, um refresh na hora 167 daria um token válido até a hora 191. - pAbast: o
subprecisa existir nopabast_senhasdo cliente, comparado comTRIM(o ERP entrega oidcom espaço na borda); senão,403 access_revoked. Sem acessoATIVO, o token sai semrole, em200, como no login — refresh e login nunca discordam, e reativar reflete sem logout. - oauth: só
ATIVOrenova;PENDENTE,REJEITADO,INATIVOou sem linha dão403 access_revoked. A conta@xadmsegueADMINimplícito, sem linha de acesso. - Cliente apagado revoga como o desativado:
403 cliente_inactive. O login devolve400 cliente_not_found, que o app não trata como revogação e o manteria logado até oexp. - Os
codede 403 são contrato com o app, que decide deslogar por eles (_codigosRevogacaonoAppConnectordo bi-comercial). O contrato vive no contrato da API, não na norma; código novo de revogação entra lá e no app.
Consequências¶
- O refresh não revoga o token anterior: o PowerSync e toda API o aceitam até o
exp. O prazo de revogação no servidor é o TTL, que a norma limita a 60 min. - Pendente, na ordem da ADR central 0037: o
AUTH_TOKEN_EXPIRATION_MINUTESde produção segue em 1440 até os apps que renovam sessão adotarem o refresh. Baixá-lo antes faria cada app sem refresh re-logar a cada TTL. No mesmo passo, o clamp de 1440 doCentralSettingscai para 60. - Como o refresh não re-prova a senha, senha trocada no ERP não derruba a sessão antes do teto. É o preço do refresh, e o teto é o limite dele.
- O rate limit é o dos logins (60 req/min por IP). Com uma chamada a cada 5 min por sessão, cabem cerca de
300 sessões atrás do mesmo IP de saída antes do
429, sem contar os eventos de foco. Com o TTL curto, o app também renova antes doexp. - O
expires_inpode vir menor que o TTL perto do teto, e o app precisa usar o valor devolvido. - Tokens emitidos antes do deploy continuam renováveis (classificação pelo
email, sessão peloiat) e somem sozinhos em até 24 h.
Alternativas consideradas¶
- Classificar o token legado por consulta ao
app_user_access(sugestão do handoff): descartada. A conta@xadmnão tem linha de acesso, cairia como pAbast e tomariaaccess_revoked. - Refresh token opaco guardado no banco: descartado na ADR central 0037. Custa tabela, rotação e detecção de reuso, e o refresh token no browser é tão exfiltrável quanto o access.
- Teto só na recusa, sem cortar o
exp: descartada, porque o último token viveria até um TTL além do teto. 403para pAbast sem papel: descartada. O refresh discordaria do login, e reativar o acesso exigiria novo login.
Glossário do projeto¶
Vocabulário do domínio de autenticação que este app usa. Termos da plataforma X-Adm (Forgejo, Coolify, Garage…) não são redefinidos aqui — linkam para o glossário da plataforma.
Tokens e chaves¶
- JWT
- JSON Web Token (RFC 7519). Token compacto e auto-contido para transmitir claims entre partes. Três partes Base64url separadas por ponto: header, payload e assinatura.
- JWKS
- JSON Web Key Set (RFC 7517). Formato padrão para publicar chaves públicas. O
endpoint
/.well-known/jwks.jsonpermite que qualquer serviço (ex. o PowerSync) obtenha a chave pública e verifique JWTs sem configuração manual. - RS256
- Algoritmo de assinatura JWT com RSA + SHA-256. Criptografia assimétrica:
a chave privada assina, a chave pública verifica. O
alg=RS256explícito no JWKS é obrigatório — o PowerSync recusa chaves sem ele. kid- Key ID — identifica qual chave pública do JWKS valida um token. Configurável
via
AUTH_KEY_ID; viabiliza rotação de chaves. - AES-256-GCM
- Cifra simétrica autenticada (AEAD) usada para guardar a senha do banco de cada
cliente (
clientes.db_pass) emdb_auth. Qualquer adulteração do ciphertext quebra a decifragem, em vez de devolver dado corrompido. (Legado — sem efeito após a centralização depabast_senhas, decisão 0003.)
Domínio de autenticação¶
- pAbast
- Sistema de autenticação proprietário do X-Adm/ERP. Usa senha de 4 dígitos
e senha complementar de 3 dígitos, guardadas como hashes em
pabast_senhas. - Algoritmo pAbast
- A função de hash do pAbast, portada sem alteração do integrador (Java) e do app Flutter (Dart) para o central-backend. A lógica idêntica nos três lugares garante compatibilidade com os hashes já gravados em produção.
- Token anônimo
- JWT emitido por
POST /api/auth/anonsem autenticar usuário. Temscope=pabast_onlye autoriza sincronizar apenaspabast_senhasvia PowerSync. Usado no bootstrap offline. - Bootstrap offline
- Primeira sincronização da tabela
pabast_senhaspara o dispositivo, feita com o token anônimo. Quebra o ciclo ovo-e-galinha (o app precisa dos hashes para logar offline, mas precisaria estar logado para sincronizá-los). Depois disso, o login offline funciona sem internet. scope- Claim do JWT que controla quais buckets o PowerSync libera:
pabast_only(só hashes de senha) oufull(todos os dados de BI). xadm_admin- Claim booleano em JWTs emitidos por
POST /api/auth/firebase/loginpara e-mails@xadm.com.br. Quandotrue, autoriza o acesso a/api/admin/**. - Firebase ID Token
- Token emitido pelo Firebase Authentication após login Google/e-mail. O
central-backend o valida pelo JWKS público do Google e, se o e-mail for
@xadm.com.br, emite um JWT próprio comxadm_admin=true.
Integrador-client (on-premise)¶
- Instalação
- Uma cópia do integrador-client rodando num cliente. O central guarda uma por cliente
(
integrador_instancias), registrada sozinha no 1º heartbeat — não há cadastro prévio além do cliente existir emclientes. - Heartbeat
- Sinal de vida que a instalação manda no startup e a cada 60 min (versão, fluxos, hostname). Vai
ao integrador-server do próprio cliente, que carimba o
cliente_ide o repassa ao central (POST /api/integrador/heartbeat, BearerCENTRAL_API_TOKEN). - Horas úteis
- Tempo contado só dentro de 07:00–22:00, de segunda a sexta, fora de feriado nacional, no fuso
America/Sao_Paulo. É a régua do alerta de silêncio — noite, fim de semana e feriado não contam. - Silêncio (episódio de)
- Instalação que passou de 6 horas úteis sem heartbeat. O central alerta uma vez por
episódio (marca
alertado_em) e o próximo heartbeat o encerra. - Situação
- Estado da instalação na lista admin:
cliente_inativo(cliente desativado no central, não alerta), senãosilencioso(episódio aberto), senãoativo.
Integração¶
- PowerSync
- Serviço de sincronização offline-first que replica dados do PostgreSQL para o
SQLite local do app Flutter. Valida JWTs via JWKS para autorizar conexões. Cada
cliente tem a sua instância (ex.
ps.vantroba.xadm.biz), mas todas apontam para o mesmojwks_urideste serviço.