Pular para conteúdo

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, 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 auth (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 (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 os oauth_access_grants.
  • cliente × cliente_idnã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 auth (fail-closed), e nunca deriva a chave do display. Mandar "Vantroba" como chave foi a raiz de um incidente (decisão 0008).
  • featuresobjeto 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 — 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-releaseguard 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 auth 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 auth/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 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 do auth): 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 do auth) e abre sessão direto; depois POST de criar appapp_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 auth. O broker grava features.analytics.metabase_dashboard_id (client-grade) pro embed futuro (corretor); a key de escrita da Metabase API é secret no auth (não no app.json). A resposta honesta do analytics reporta 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-setup grava aptabase_key + metabase_dashboard_id e, antes dos eventos, pergunta o arquétipo do appproduto (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 pipeline docs.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, §7). 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 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 do auth.
  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ávelembed 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.jsonapp_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 appnã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:

  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/.