Central de Apps¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-12
Aplica-se a: perfil app, toda stack.
A Central de Apps liga as funcionalidades de infra da X-Adm (erros, arquivos, analytics, docs) a cada app — a maior parte em build-time, não amarrada em runtime.
- O app declara o que usa no seu
app.json(manifesto único, estilofirebase.json). - A skill
/xadm-setup(roda depois de codar, antes da/xadm-release) lê oapp.json, pergunta o que ativar e, para cada "sim", provisiona via ocentral-backend(que tem os tokens admin) e grava o resultado client-grade de volta noapp.json— que o build embarca. Nada de buscar config no boot. - Runtime, só o mínimo: corretores (URLs assinadas: embed Metabase, presign Garage). As
feature flags têm protocolo próprio (estado no banco do app,
central-backend= painel).
Norma × detalhe
A norma está na constituição. Aqui, o contrato operável.
Por que build-time (e o que fica runtime)¶
DSN do GlitchTip, app key do Aptabase, bucket do Garage são client-grade e ~estáticos — mudam
quase nunca e já vão embutidos no bundle (ver Flutter). Provisionar
(criar projeto/bucket) é setup raro. Publicar docs já é build-time (job docs do pipeline.yml + app.json).
Então setup e config são build-time. Fica em runtime só o que precisa: corretores
(URLs assinadas/expiráveis) e feature flags (dinâmicas).
Papéis¶
| Serviço | Papel |
|---|---|
central-backend.xadm.biz (alias: auth.xadm.biz) |
central-backend — Broker de provisão (build-time; tem os tokens admin dos provedores) + configurador de env (injeta a env de runtime no recurso Coolify no /xadm-setup) + corretor (runtime) + autenticação (JWT/JWKS, pAbast/anon/Firebase, acessos de terceiros) + catálogo de papéis (0029). auth.xadm.biz é alias permanente do mesmo serviço (issuer do JWT e bundles publicados), roteado para o mesmo container (deploy). |
central.xadm.biz |
central-ui — Dashboard (Flutter web/android): mostra o configurado + links, aprova/recusa acesso de terceiros. Audiência admin xadm. Não provisiona (isso é o /xadm-setup). Origem distinta do backend — por isso toda superfície /api/** do central-backend nasce com filtro de CORS (Java/Micronaut §Específico). |
sso.xadm.biz |
Forward-auth (login Google) das UIs internas (docs, Forgejo, Metabase, GlitchTip). Distinto do central-backend. |
app.json — o manifesto único¶
Um só arquivo declara tudo: metadados de docs + identidade + funcionalidades. O
/xadm-setup preenche cada bloco de features com os refs provisionados (client-grade —
commitável, não é segredo):
{
"slug": "vantroba-bi",
"app_id": "vantroba-bi",
"nome": "BI",
"grupo": "cliente",
"cliente": "Vantroba",
"cliente_id": "vantroba",
"stack": "Dart/Flutter",
"production_url": "https://bi.vantroba.xadm.biz",
"toolchain": { "flutter": "3.44.0" },
"features": {
"glitchtip": { "enabled": true, "dsn": "https://<chave>@bug.xadm.biz/<n>" },
"garage": { "enabled": true, "bucket": "vantroba", "endpoint": "https://arquivo-api.xadm.biz" },
"analytics": { "enabled": true, "aptabase_key": "A-SH-...", "aptabase_host": "https://aptabase.xadm.biz", "metabase_dashboard_id": 42 },
"docs": { "enabled": true }
}
}
app_id— identidade canônica (kebab), um só namespace com osoauth_access_grants.cliente×cliente_id— não confundir:clienteé o rótulo de exibição ("Vantroba"— label da coleção Metabase, path no portal);cliente_idé a chave canônica do tenant (clientes.cliente_id, minúscula kebab,"vantroba"). O broker escopa coleção/grants pela chave; o/xadm-setupmandacliente_idvalidado contra o catálogo docentral-backend(fail-closed), e nunca deriva a chave do display. Mandar"Vantroba"como chave foi a raiz de um incidente (decisão 0008).features— objeto com chaves do vocabulário fechado (glitchtip,garage,analytics,docs); cada bloco temenabled+ os refs que o/xadm-setupgravou. O validador rejeita chave desconhecida.production_url— URL de produção (acentrallinka). Docs/source acentralderiva deslug/repo.- Feature flags (bloco
flags) têm protocolo próprio, fora desta página. - Segredo fica FORA do
app.json(é público, servido no portal): só entra client-grade. A key de escrita do Garage (server/web) é Secret no Coolify (ver Garage).
Ciclo de setup (build-time) + os dois checkpoints¶
sequenceDiagram
participant Dev
participant Setup as /xadm-setup
participant Auth as central-backend (broker)
participant Prov as Provedor
Dev->>Setup: roda (com o token de serviço de setup)
Setup->>Setup: lê app.json (o que já está enabled)
Setup->>Dev: usar GlitchTip? Garage? Analytics? Docs?
Setup->>Auth: POST /api/setup/provision (app_id, feature + identidade do app.json)
Auth->>Prov: cria projeto/bucket/app (idempotente, token admin só aqui)
Prov-->>Auth: ref + client-grade
Auth-->>Setup: { dsn / bucket / key ... } + upsert no catálogo
Setup->>Dev: grava no app.json + registra secret + scaffolda helper de analytics
Note over Dev: build embarca o app.json → app usa a config (sem boot-fetch)
Dois checkpoints:
1. /xadm-setup — provisiona, grava o app.json, injeta a env de runtime do server no recurso
Coolify, e scaffolda um helper de analytics (+ guia de onde chamar login/logout/clique/CRUD;
não auto-edita o código). Idempotente.
2. /xadm-release — guard soft: app.json sem os campos da Central → pergunta uma vez
(configurar? / "nenhuma"). Não bloqueia app trivial.
Resposta honesta do /provision (nunca engolir falha). A resposta carrega, por provider, o
estado real — provisionamento (state ligada/falha + last_error) e o resultado da
injeção da env no Coolify (injected: true|false + motivo). Não-2xx se nenhum provider
ligou. Assim o /xadm-setup distingue sucesso de "200 com enabled:true sem dsn": em falha de
provisão reporta o last_error e não grava enabled sem o ref; injected:false significa
setar o env à mão no recurso (recurso ainda não criado, token inválido, erro), e o /xadm-setup
avisa o dev. A injeção é best-effort, mas o resultado é sempre reportado, nunca só logado em WARN.
Injetar APLICA a config (reinicia), não só grava. Env upsertado não recarrega em processo
vivo — o container segue com o valor antigo até o próximo deploy. Logo, reiniciar o recurso
Coolify faz parte da injeção: injected:true significa "injetado E em vigor", não só gravado.
Reinicia só quando o valor mudou — o broker lê o valor atual e compara antes de setar; re-run
do setup com config idêntica não derruba o recurso (evita indisponibilidade à toa em serviço
sensível, ex. central-backend = login). O mecanismo de restart/redeploy roda contra a API do Coolify junto do
upsert de env (ver coolify).
Duas naturezas de config × plataforma¶
Fonte é sempre o app.json; a entrega difere por natureza e por plataforma. O web e o
mobile embarcam a config no build, que roda no CI; o server lê do env em runtime, então a
config dele vira env do recurso Coolify, injetada pelo broker.
| Web e mobile (Flutter, build no CI) | Server (recurso Coolify) | |
|---|---|---|
| client-grade (DSN, app key, bucket) | vem do app.json e vira --dart-define no CI |
env de runtime do recurso, injetada pelo broker no /xadm-setup |
segredo (SENTRY_AUTH_TOKEN, key de escrita do Garage) |
secret do repo no GitHub, lido pelo build; mobile não tem key de escrita → Garage por presign em runtime | env Secret de runtime do recurso, injetada pelo broker no /xadm-setup |
Timing: se o recurso Coolify ainda não existe quando o /xadm-setup roda, a resposta volta
injected:false e o env se seta à mão quando o recurso for criado (ou o /xadm-setup roda de
novo). Mecânica do endpoint (upsert de env, "Secret" = env marcada): ver
coolify.
Auth do broker de setup¶
O /api/setup/** é máquina-a-máquina (o /xadm-setup, ferramenta de dev/CI) — autentica por
token de serviço, não por login humano (não há tela nem loopback-OAuth pra um CLI obter JWT).
Importa de verdade: o /provision recebe production_url e injeta env no recurso Coolify
correspondente — num endpoint aberto, um token fraco deixaria injetar env em recurso de terceiro
cujo domínio se conheça.
- A força casa com a exposição (o
central-backenddeclara qual é): fora da rota pública (rede interna/VPN) → token simples basta; público → segredo forte (aleatório) ou rotativo por HMAC-de-data-com-chave (rotaciona sozinho, impossível de forjar sem a chave). Token de fórmula pública (poucas dezenas de valores) não é aceitável exposto. - A regra de geração vive fora de banda: no código do servidor + com os admins — nunca no
template da skill nem em doc publicada (
docs/é público). A skill conhece só o formato e pede o valor ao usuário; jamais o embute. (Registrar "como obter a credencial de setup" é parte do contrato do app.)
Provedores¶
Toda provisão é idempotente = procura-antes-de-cria: o provider checa se o recurso já existe
(por nome/app_id) e reusa, nunca cria às cegas. Onde o provedor permite nome duplicado (ex.
GlitchTip) o find é obrigatório — sem ele, um re-run (ou uma falha no find seguida de nova
chamada) duplica o recurso. "Idempotente" é o mecanismo, não só a palavra. Refs client-grade →
app.json; segredos → só no central-backend/destino de build.
- glitchtip (
bug.xadm.biz) — baseline (todo app com release): projeto por app →dsn. O broker nomeia o projeto peloapp_id(slug estável); odsnusa o id numérico do projeto, não o slug.SENTRY_AUTH_TOKEN(sourcemap) = secret. - garage (
arquivo-api.xadm.biz) — bucket + key:bucket/endpointnoapp.json; key de escrita = secret (web/server) ou presign (mobile). - analytics (
aptabase.xadm.biz/metabase.xadm.biz) — o Aptabase não tem API de gestão e autentica por sessão. O token de sessão do Aptabase é um JWT simétrico (HS256) assinado com um secret do próprio tool (decisão 0013 docentral-backend) → o broker forja o token (secret no env docentral-backend) e abre sessão direto; depois POST de criar app →app_idA-SH-…=aptabase_key. O client_grade inclui tambémaptabase_host(o URL público, ex.https://aptabase.xadm.biz) — a keyA-SH-self-hosted é inútil no cliente sem o host (o SDK Flutter exigeInitOptions(host:), ≠ do DSN do GlitchTip que já embute o host). O broker devolveaptabase_key+aptabase_host(não só a key). Robusto, sem depender de infra (nem UI, nem logs). É o padrão "provider sem API → forjar o token" (↓). Guardas: fail-fast; versão fixada (claims/endpoints internos podem mudar); o secret forja sessão de qualquer usuário (credencial poderosa). Idempotente: lista os apps e reusa o de nome =app_id. Guarde o resultado na resposta honesta do/provision(state/last_error). O Metabase tem API real (provisão robusta, ≠ Aptabase): o broker cria a estrutura padrãocollection(grupo/cliente) → sub-pasta(app_id) → dashboard + cards— a collection é por grupo/cliente (app da X-Adm → "X-Adm"; app de cliente → nome do cliente), e cada app ganha uma sub-pasta (collection filha, nome =app_id) que contém o dashboard + os cards. A sub-pasta isola os cards → eles voltam ao nome canônico (sem sufixar comapp_id). Espelha oapp.json(grupo/cliente→ app). Cards baseline que adaptam aos eventos escolhidos (universais sempre; "Telas Mais Usadas" só sescreen_view; "Ações" = os custom). As 6 queries ClickHouse são template canônico no broker (não lidas do dashboard ad-hoc). Idempotente por id estável — collection porgrupo/cliente, sub-pasta porapp_id, dashboard/cards dentro dela. Decisão 0014 docentral-backend. O broker gravafeatures.analytics.metabase_dashboard_id(client-grade) pro embed futuro (corretor); a key de escrita da Metabase API é secret nocentral-backend(não noapp.json). A resposta honesta doanalyticsreporta os dois sub-estados (aptabase+metabase) separados. O/xadm-setupgravaaptabase_key+metabase_dashboard_ide, antes dos eventos, pergunta o arquétipo do app — produto (muitos usuários; importa comportamento/ retenção → baseline login/logout/screen_view/CRUD, cards de usuários ativos/telas/versão) ou interno/admin (poucos usuários; importa auditar a ação sensível → eventos da ação de cada fluxo, ex. aprovar/rejeitar/publicar/resetar;screen_viewe retenção têm valor baixo) — e adapta os eventos sugeridos e o card set. Caveat: evento client-side é bypassável → visibilidade operacional, não audit trail; auditoria de segurança de verdade (a ação sensível como registro imutável) pertence ao backend, não ao Aptabase. - docs (
docs.xadm.biz/docs-sites) — o jobdocsdopipeline.ymlexistente.
Três formas de provisionar um recurso (escolha pela capacidade do provider):
- Find-before-create via API real (GlitchTip / Garage / Metabase) — lista por identidade estável e reusa; cria se falta (procura-antes-de-cria). O caso normal.
- Forjar o token de sessão (Aptabase — provider sem API de gestão): o tool autentica por
JWT simétrico (HS256) assinado com um secret do próprio tool → o broker forja o token
(secret no env do
central-backend) e abre sessão direto — em vez de automatizar a UI ou raspar logs (que nem sempre são legíveis; o Coolify não expõe logs de service). Robusto, sem dependência de infra. Guardas: fail-fast, versão fixada (claims/endpoints internos mudam sem aviso), o secret forja sessão de qualquer usuário (credencial poderosa). Decisão 0013 docentral-backend. - Reuso-persistido (allowlist) — para o que não deve re-verificar a cada run: persiste o grant/ref numa allowlist e reusa direto, sem re-consultar o provider (evita re-trabalho e chamada desnecessária onde a re-verificação não agrega).
(Armadilhas de cliente REST do broker — PUT que substitui coleção, nomear por escopo em container
compartilhado — em java-micronaut.)
Corretores (runtime, para segredo)¶
Quando o derivado é secreto e expirável — embed do Metabase, presign do Garage
(mobile) — o central-backend assina sob demanda e devolve só a URL (GET /api/apps/metabase/embed,
GET /api/apps/garage/presign). O app chama com o JWT do usuário logado; o segredo nunca sai
do central-backend.
O corretor autoriza por app, não só autentica. JWT válido é autenticação; antes de assinar, o corretor confere que o claim do JWT casa com o app do recurso pedido — usuário logado no app A não cunha embed/presign de recurso do app B (403 cross-app). É invariante de segurança normativa: todo corretor escopa pelo app do claim.
Catálogo (apps) — para o dashboard¶
O central-backend mantém o catálogo apps (por app_id). É populado pelo
/api/setup/provision (o setup tem o app_id + os dados do app.json). O pipeline de docs
publica o app.json só para o portal público — não é fonte do catálogo. O catálogo serve o
dashboard, o escopo dos acessos e a ancoragem dos oauth_access_grants.
Contrato de campos obrigatórios do /provision — travado nos dois lados. O payload não é só
(app_id, feature): carrega o bloco de identidade do app.json — app_id, feature, grupo,
nome, production_url e, para grupo=cliente, o cliente_id (a chave canônica,
minúscula — decisão 0008). Os dois primeiros passam
batido por não serem identidade de tenant: sem nome o broker devolve 400 nome_obrigatorio;
sem production_url o secret não é injetado (é a env que o broker casa ao recurso Coolify —
Auth do broker — logo o app cai no gate "mobile/local" e o server fica
sem write-key silenciosamente).
Esse contrato não vive só no /xadm-setup: o broker do central-backend tem um teste que rejeita payload
sem os campos obrigatórios (400 legível, nunca 500), e a skill declara os mesmos campos.
Sem o par, os dois se afastam em silêncio. É a aplicação da
definição de pronto (contrato de entrada
first-party travado nos dois lados) — detalhe em CI, gates e testes.
Provisão é por app_id; acesso é por (cliente, app). O recurso provisionado (DSN, bucket,
key) é do app — não há tabela de "pares (cliente, app)". O par existe só para o acesso:
é derivado dos oauth_access_grants (cliente × app × usuário). Não reintroduza uma tabela de
pares para a provisão.
Central (dashboard)¶
A central.xadm.biz mostra, por (cliente, app): o que está configurado (do app.json/
catálogo) e os links do app — produção (production_url), docs (do slug), source (Forgejo),
analytics (dashboard Metabase, corretado), erros (projeto GlitchTip). Ações: aprova/recusa
acesso de terceiros (sign-in-with Google). Não provisiona infra — isso é o /xadm-setup.
Fronteira de dados¶
| Domínio | Onde mora |
|---|---|
Autenticação/autorização (clientes, pAbast, acessos de terceiros, auth_methods, JWKS) |
DB do central-backend |
| Registro/catálogo da plataforma (apps, o que o app declara) | central-backend |
| Dados de domínio + estado de flags | DB do app |
cliente_id = chave canônica (FK) no central-backend; no app é coluna de escopo vinda do claim
do JWT (validado pelo JWKS do central-backend) — sem FK cross-DB, o token é a autoridade. Config de
autenticação ≠ funcionalidade da Central (mora no central-backend, gerida pelo admin existente).
Governança e segredos¶
Tokens admin dos provedores, embed/presign secrets e a cred da API do Coolify vivem só no
central-backend (env Secret do recurso); nunca em docs/, no app.json (público) nem no bundle. Quem
provisiona = o dev via /xadm-setup (com o central-backend como broker); quem aprova acesso = admin
xadm na central.
Runbook — Forgejo no VSCode (issues)¶
Para gerir issues do Forgejo (fonte.xadm.biz, v15) no editor, reaproveite a extensão
existente:
- Instale Forgejo Integration (
maxking.forgejo-vscode) do Marketplace ou Open VSX. - Gere um PAT no Forgejo (User Settings → Applications), escopos
read:repository+read:issues(+write:repositoryopcional). - A extensão guarda o token no SecretStorage do VSCode; instância zero-config em remote HTTPS.
Tokens diferentes
O PAT do Forgejo (por dev, IDE) é distinto de qualquer credencial de app. Nenhum mora em docs/.