Pular para conteúdo

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, estilo firebase.json).
  • A skill /xadm-setup (roda depois de codar, antes da /xadm-release) lê o app.json, pergunta o que ativar e, para cada "sim", provisiona via o central-backend (que tem os tokens admin) e grava o resultado client-grade de volta no app.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 os oauth_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-setup manda cliente_id validado contra o catálogo do central-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 tem enabled + os refs que o /xadm-setup gravou. O validador rejeita chave desconhecida.
  • production_url — URL de produção (a central linka). Docs/source a central deriva de slug/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-backend declara 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 pelo app_id (slug estável); o dsn usa o id numérico do projeto, não o slug. SENTRY_AUTH_TOKEN (sourcemap) = secret.
  • garage (arquivo-api.xadm.biz) — bucket + key: bucket/endpoint no app.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 do central-backend) → o broker forja o token (secret no env do central-backend) e abre sessão direto; depois POST de criar app → app_id A-SH-… = aptabase_key. O client_grade inclui também aptabase_host (o URL público, ex. https://aptabase.xadm.biz) — a key A-SH- self-hosted é inútil no cliente sem o host (o SDK Flutter exige InitOptions(host:), ≠ do DSN do GlitchTip que já embute o host). O broker devolve aptabase_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ão collection(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 com app_id). Espelha o app.json (grupo/cliente → app). Cards baseline que adaptam aos eventos escolhidos (universais sempre; "Telas Mais Usadas" só se screen_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 por grupo/cliente, sub-pasta por app_id, dashboard/cards dentro dela. Decisão 0014 do central-backend. O broker grava features.analytics.metabase_dashboard_id (client-grade) pro embed futuro (corretor); a key de escrita da Metabase API é secret no central-backend (não no app.json). A resposta honesta do analytics reporta os dois sub-estados (aptabase + metabase) separados. O /xadm-setup grava aptabase_key + metabase_dashboard_id e, 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_view e 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 job docs do pipeline.yml existente.

Três formas de provisionar um recurso (escolha pela capacidade do provider):

  1. 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.
  2. 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 do central-backend.
  3. 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:

  1. Instale Forgejo Integration (maxking.forgejo-vscode) do Marketplace ou Open VSX.
  2. Gere um PAT no Forgejo (User Settings → Applications), escopos read:repository + read:issues (+ write:repository opcional).
  3. 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/.