Central de Apps¶
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 oauth(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 (spec 014 — estado no DB do app,
auth= painel).
Norma × detalhe
A norma está na constituição §7. 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 (docs.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 — spec 014).
Papéis¶
| Serviço | Papel |
|---|---|
auth.xadm.biz |
Broker de provisão (build-time; tem os tokens admin dos provedores) + configurador de secrets (injeta no Coolify/CI no deploy) + corretor (runtime) + autenticação (JWT/JWKS, pAbast/anon/Firebase, acessos de terceiros). |
central.xadm.biz |
Dashboard (Flutter web/android): mostra o configurado + links, aprova/recusa acesso de terceiros. Audiência admin xadm. Não provisiona (isso é o /xadm-setup). |
sso.xadm.biz |
Forward-auth (login Google) das UIs internas (docs, Forgejo, Metabase, GlitchTip). Distinto do auth. |
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": "bi-transporte",
"app_id": "bi-vantroba",
"nome": "BI Transporte",
"grupo": "cliente",
"cliente": "Vantroba",
"cliente_id": "vantroba",
"stack": "Flutter/Dart",
"production_url": "https://bi-transporte.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 doauth(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 — spec 014, não aqui. - 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 três checkpoints¶
sequenceDiagram
participant Dev
participant Setup as /xadm-setup
participant Auth as auth (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)
Três checkpoints:
1. /xadm-setup — provisiona, grava o app.json, registra os secrets necessários, 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.
3. Deploy (passo no ci.yml/docs.yml) — guard: garante a config no destino
(client-grade de server + secret); injeta se faltar; re-verifica os refs (drift).
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 do secret/config 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; em injected:false
avisa o dev (recurso Coolify ausente → o guard de deploy injeta depois; token inválido/erro → setar
o env à mão). A injeção é best-effort no setup, 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. auth = login). O mecanismo de restart/redeploy roda contra a API do Coolify junto do
upsert de env (ver coolify).
Provado no auth: /test/glitchtip seguiu no DSN velho com injected:true até o restart manual
(decisão 0011).
Duas naturezas de config × plataforma¶
Fonte é sempre o app.json; a entrega difere por natureza e por plataforma. O que o
broker leva ao destino é config (client-grade + secret), não só secret — e web ≠ server:
o web embarca no bundle no build; o server lê do env em runtime, então o client-grade dele
também vira env no Coolify (injetado), não build-arg.
| Web (Coolify) | Server (Coolify) | Mobile (CI, sem Coolify) | |
|---|---|---|---|
| client-grade (DSN, app key, bucket) | build-arg (embarca no bundle) | env de runtime — o broker injeta | --dart-define no flutter build |
secret (SENTRY_AUTH_TOKEN, key de escrita Garage) |
env Secret build-time (upload de sourcemap) | env Secret de runtime — o broker injeta | org-CI herdado (org-wide); sem key de escrita per-app → Garage via presign runtime |
Timing: a injeção de config no Coolify é deploy-time — no /xadm-setup o recurso Coolify
pode não existir (1ª vez); o setup registra a necessidade, o guard de deploy injeta quando o
recurso existe. 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
authdeclara 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 auth/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 magic-link/sessão. > Correção 2026-07-03: o "magic-link dos logs do Coolify" não funciona — o Coolify não expõe logs de service. O real (empírico, decisão 0013 doauth): o token de sessão do Aptabase é um JWT simétrico (HS256) assinado com um secret do próprio tool → o broker forja o token (secret no env doauth) 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 doauth. O broker gravafeatures.analytics.metabase_dashboard_id(client-grade) pro embed futuro (corretor); a key de escrita da Metabase API é secret noauth(não noapp.json). A resposta honesta doanalyticsreporta os dois sub-estados (aptabase+metabase) separados. O dashboard "X-Adm" ad-hoc atual fica órfão (limpeza manual, REGRA Nº 3); conexão ClickHouse = assumida (existe). 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 pipelinedocs.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, §7). 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
auth) 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 doauth. - 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 auth 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 auth.
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 auth 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
e, para grupo=cliente, o cliente_id (a chave canônica, minúscula — decisão 0008).
Esse contrato não vive só no /xadm-setup: o broker do auth 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 driftam em silêncio — foi o que aconteceu quando o guia dizia (app_id, feature) e
o broker passou a exigir mais: 500 em produção em vários apps antes de alguém notar. É a aplicação
concreta do piso constituição §6 (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 auth |
| Registro/catálogo da plataforma (apps, o que o app declara) | auth |
| Dados de domínio + estado de flags | DB do app |
cliente_id = chave canônica (FK) no auth; no app é coluna de escopo vinda do claim
do JWT (validado pelo JWKS do auth) — sem FK cross-DB, o token é a autoridade. Config de
autenticação ≠ funcionalidade da Central (mora no auth, 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
auth (env ← org Forgejo); nunca em docs/, no app.json (público) nem no bundle. Quem
provisiona = o dev via /xadm-setup (com o auth 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/.